# Routing & Responses

> Route files registered, JSON responses shaped, a task controller.

Goravel series, part 2 of 20. By Krishan Kumar. Published 2026-03-13, Updated 2026-10-25.
Source: https://devchalk.com/goravel/02-routing-response
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-02

TaskFlow runs, but it does nothing yet. In this part we add routes, send back text, HTML and JSON, move the API routes into their own file, and move their handlers into a controller. Before we start, let's set up a tool that restarts the server for you.

Start from the Installation & Project Architecture code:

```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-01
cp .env.example .env
go run . artisan key:generate
docker compose up -d
```

## Hot reload with Air

Right now you have to restart the server after every change. [Air](https://github.com/air-verse/air) watches the project and restarts it for you.

```bash
$ go install github.com/air-verse/air@latest
$ air -v
  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_
v1.67.4, built with Go go1.27.3
```

If `go run .` is still running, stop it with Ctrl+C. From now on, start the app with Air instead:

```bash
air
```

Air watches `.go`, `.tpl`, `.tmpl` and `.html` files. Change a route file, save, and the server restarts by itself.

Air's settings are in `.air.toml` at the project root. This is the part that matters here:

```toml title=".air.toml"
[build]
  cmd = "go build -o ./tmp/main.exe ."
  delay = 1000
  exclude_dir = ["storage", "database", "tmp"]
  include_ext = ["go", "tpl", "tmpl", "html"]
```

`delay` is how many milliseconds Air waits after a save before it rebuilds. You can leave it, or set it to `500` to restart faster.

## The route file

Open `routes/web.go`. Routes are registered through the [`Route` facade](https://www.goravel.dev/the-basics/routing), with a method per HTTP verb. The skeleton already has a few. The highlighted one returns the welcome page:

```go title="routes/web.go" showLineNumbers {12-16}
package routes

import (
	"github.com/goravel/framework/contracts/http"
	"github.com/goravel/framework/support"

	"taskflow/app/http/controllers"
	"taskflow/app/facades"
)

func Web() {
	facades.Route().Get("/", func(ctx http.Context) http.Response {
		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
			"version": support.Version,
		})
	})

	facades.Route().Static("public", "./public")

	userController := controllers.NewUserController()
	facades.Route().Get("/users", userController.Index)
}
```

It returns a view, an HTML template from `resources/views`.

The first argument is the path and the second is the handler. A handler takes a Goravel `http.Context` and returns an `http.Response`. Every handler in this series looks like this. The leading slash in the path is optional. `hello` and `/hello` are the same route.

## Response types

A route can answer with plain text, HTML or JSON. Start with plain text:

```diff lang="go" title="routes/web.go" collapse={1-19}
 package routes
 
 import (
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/support"
 
 	"taskflow/app/http/controllers"
 	"taskflow/app/facades"
 )
 
 func Web() {
 	facades.Route().Get("/", func(ctx http.Context) http.Response {
 		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
 			"version": support.Version,
 		})
 	})
 
 	facades.Route().Static("public", "./public")
 
 	userController := controllers.NewUserController()
 	facades.Route().Get("/users", userController.Index)
+
+	facades.Route().Get("hello", func(ctx http.Context) http.Response {
+		return ctx.Response().String(200, "Hello, world")
+	})
 }
```

Save, let Air restart, and check it with curl in a second terminal, since Air keeps the first one busy. The `-i` flag prints the headers too:

```bash
$ curl -i http://localhost:3000/hello
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Hello, world
```

The content type is `text/plain`. To send HTML instead, set the `Content-Type` header:

```diff lang="go" title="routes/web.go" collapse={1-21}
 package routes
 
 import (
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/support"
 
 	"taskflow/app/http/controllers"
 	"taskflow/app/facades"
 )
 
 func Web() {
 	facades.Route().Get("/", func(ctx http.Context) http.Response {
 		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
 			"version": support.Version,
 		})
 	})
 
 	facades.Route().Static("public", "./public")
 
 	userController := controllers.NewUserController()
 	facades.Route().Get("/users", userController.Index)
 
 	facades.Route().Get("hello", func(ctx http.Context) http.Response {
-		return ctx.Response().String(200, "Hello, world")
+		return ctx.Response().Header("Content-Type", "text/html").String(200, "<h1>Hello, world</h1>")
 	})
 }
```

Open [localhost:3000/hello](http://localhost:3000/hello) in the browser and the text shows as a heading.

For an API you usually want JSON. `Json` takes a status code and any value. A map is the simplest:

```diff lang="go" title="routes/web.go" collapse={1-21}
 package routes
 
 import (
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/support"
 
 	"taskflow/app/http/controllers"
 	"taskflow/app/facades"
 )
 
 func Web() {
 	facades.Route().Get("/", func(ctx http.Context) http.Response {
 		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
 			"version": support.Version,
 		})
 	})
 
 	facades.Route().Static("public", "./public")
 
 	userController := controllers.NewUserController()
 	facades.Route().Get("/users", userController.Index)
 
 	facades.Route().Get("hello", func(ctx http.Context) http.Response {
-		return ctx.Response().Header("Content-Type", "text/html").String(200, "<h1>Hello, world</h1>")
+		return ctx.Response().Json(200, map[string]any{
+			"message": "Hello, world",
+		})
 	})
 }
```

Reload `/hello` and you get:

```json title="GET /hello"
{
  "message": "Hello, world"
}
```

There are [other response types](https://www.goravel.dev/the-basics/response) too:

- `File` sends a file.
- `Data` sends raw bytes with a content type.
- `Stream` sends data to the client as it is produced.

## Grouping routes under a prefix

Every API route in this series lives under `/api/v1`. If we ever make a v2, it can live next to v1, and apps that still call v1 keep working. Instead of writing the prefix on each route, put it on a [group](https://www.goravel.dev/the-basics/routing#group-routing):

```diff lang="go" title="routes/web.go" collapse={8-28}
 package routes
 
 import (
 	"github.com/goravel/framework/contracts/http"
+	"github.com/goravel/framework/contracts/route"
 	"github.com/goravel/framework/support"
 
 	"taskflow/app/http/controllers"
 	"taskflow/app/facades"
 )
 
 func Web() {
 	facades.Route().Get("/", func(ctx http.Context) http.Response {
 		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
 			"version": support.Version,
 		})
 	})
 
 	facades.Route().Static("public", "./public")
 
 	userController := controllers.NewUserController()
 	facades.Route().Get("/users", userController.Index)
 
 	facades.Route().Get("hello", func(ctx http.Context) http.Response {
 		return ctx.Response().Json(200, map[string]any{
 			"message": "Hello, world",
 		})
 	})
+
+	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
+		router.Post("tasks", func(ctx http.Context) http.Response {
+			return ctx.Response().String(200, "task created")
+		})
+	})
 }
```

Everything registered on `router` gets the prefix. `POST` is the method for creating things. We send real task data in a later part.

## Testing with the HTTP client

You cannot make a `POST` from the address bar. [Postman](https://www.postman.com) and [curl](https://curl.se) both work. In this series I use the [HTTP client built into GoLand](https://www.jetbrains.com/help/go/http-client-in-product-code-editor.html), because the requests live in the project next to the code. Create `requests.http` in the project root:

```http title="requests.http"
### Create task
POST http://localhost:3000/api/v1/tasks

### Hello
GET http://localhost:3000/hello
```

Each request starts with `###` and a name, then the method and the URL. Note the prefix on the tasks route.

Click the run icon next to a request and the response opens in the editor. In VS Code, the REST Client extension runs the same file. Commit it with the code, and everyone on the project gets the same requests.

Without GoLand, use curl:

```bash
$ curl -X POST http://localhost:3000/api/v1/tasks
task created
```

## Named routes

A route can have a [name](https://www.goravel.dev/the-basics/routing#set-route-name). Your code can then find the route by its name instead of its path:

```diff lang="go" title="routes/web.go" collapse={1-30} t1:route-name="tasks.create"
 package routes
 
 import (
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/contracts/route"
 	"github.com/goravel/framework/support"
 
 	"taskflow/app/http/controllers"
 	"taskflow/app/facades"
 )
 
 func Web() {
 	facades.Route().Get("/", func(ctx http.Context) http.Response {
 		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
 			"version": support.Version,
 		})
 	})
 
 	facades.Route().Static("public", "./public")
 
 	userController := controllers.NewUserController()
 	facades.Route().Get("/users", userController.Index)
 
 	facades.Route().Get("hello", func(ctx http.Context) http.Response {
 		return ctx.Response().Json(200, map[string]any{
 			"message": "Hello, world",
 		})
 	})
 
 	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
 		router.Post("tasks", func(ctx http.Context) http.Response {
 			return ctx.Response().String(200, "task created")
-		})
+		}).Name("tasks.create")
 	})
 }
```

Ask the router about the route by its name:

```diff lang="go" title="routes/web.go" collapse={1-22, 31-36} t1:route-name="tasks.create"
 package routes
 
 import (
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/contracts/route"
 	"github.com/goravel/framework/support"
 
 	"taskflow/app/http/controllers"
 	"taskflow/app/facades"
 )
 
 func Web() {
 	facades.Route().Get("/", func(ctx http.Context) http.Response {
 		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
 			"version": support.Version,
 		})
 	})
 
 	facades.Route().Static("public", "./public")
 
 	userController := controllers.NewUserController()
 	facades.Route().Get("/users", userController.Index)
 
 	facades.Route().Get("hello", func(ctx http.Context) http.Response {
-		return ctx.Response().Json(200, map[string]any{
-			"message": "Hello, world",
-		})
+		return ctx.Response().Json(200, facades.Route().Info("tasks.create"))
 	})
 
 	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
 		router.Post("tasks", func(ctx http.Context) http.Response {
 			return ctx.Response().String(200, "task created")
 		}).Name("tasks.create")
 	})
 }
```

Open `/hello` and you see the route's path, name, method and handler:

```json title="GET /hello" t1:route-name=/"name": "(tasks.create)"/
{
  "handler": "taskflow/routes.Web.func3.1",
  "method": "POST",
  "name": "tasks.create",
  "path": "/api/v1/tasks"
}
```

If the path changes later, code that uses the name keeps working.

## Separate the API from web routes

`routes/web.go` is for pages, public files and test routes like `/hello`. API routes are easier to manage in their own file. Create `routes/api.go`:

```go title="routes/api.go" t2:api=/func (Api)[(]/
package routes

import (
	"taskflow/app/facades"

	"github.com/goravel/framework/contracts/http"
	"github.com/goravel/framework/contracts/route"
)

func Api() {
	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
		router.Post("tasks", func(ctx http.Context) http.Response {
			return ctx.Response().String(200, "task created")
		}).Name("tasks.create")
	})
}
```

Move the API routes out of `web.go`:

```diff lang="go" title="routes/web.go" collapse={8-23}
 package routes
 
 import (
 	"github.com/goravel/framework/contracts/http"
-	"github.com/goravel/framework/contracts/route"
 	"github.com/goravel/framework/support"
 
 	"taskflow/app/http/controllers"
 	"taskflow/app/facades"
 )
 
 func Web() {
 	facades.Route().Get("/", func(ctx http.Context) http.Response {
 		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
 			"version": support.Version,
 		})
 	})
 
 	facades.Route().Static("public", "./public")
 
 	userController := controllers.NewUserController()
 	facades.Route().Get("/users", userController.Index)
 
 	facades.Route().Get("hello", func(ctx http.Context) http.Response {
 		return ctx.Response().Json(200, facades.Route().Info("tasks.create"))
 	})
-
-	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
-		router.Post("tasks", func(ctx http.Context) http.Response {
-			return ctx.Response().String(200, "task created")
-		}).Name("tasks.create")
-	})
 }
```

> **Gotcha** When you save, `POST /api/v1/tasks` stops working, and there is no error message. The route is gone, so the answer should be `404 Not Found`. The client gets `200 OK` with an empty body instead, so it looks like the request worked. The only sign is the 404 in the server's request log. `/hello` also returns empty fields until you register the file in the next step.

Goravel does not discover route files. You register them in `bootstrap/app.go`:

```diff lang="go" title="bootstrap/app.go" collapse={1-12} t2:api=/routes[.](Api)[(]/
 package bootstrap
 
 import (
 	contractsfoundation "github.com/goravel/framework/contracts/foundation"
 	"github.com/goravel/framework/foundation"
 
 	"taskflow/config"
 	"taskflow/routes"
 )
 
 func Boot() contractsfoundation.Application {
 	return foundation.Setup().
 		WithMigrations(Migrations).
 		WithRouting(func() {
+			routes.Api()
 			routes.Web()
 		}).
 		WithProviders(Providers).
 		WithConfig(config.Boot).
 		Create()
 }
```

*One route table for the whole app. Send the request, then register `api.go` and send it again.*

Until bootstrap/app.go calls routes.Api(), the route table has only the routes from web.go, and POST /api/v1/tasks matches nothing: the client gets 200 OK with an empty body and the server logs a 404. After routes.Api() is added, the request matches and the handler answers "task created".

Save again, then send the `POST` and open `/hello`. Both answer again. Every new route file needs a line like this in `bootstrap/app.go`.

## Controllers

The create task handler is in `routes/api.go`. Every part adds routes, so the file would soon hold the whole API. A [controller](https://www.goravel.dev/the-basics/controllers) is a struct whose methods are handlers, and the route file keeps only the list of routes. The project came with one: `UserController`, behind `/users` in `web.go`.

Make one for tasks:

```bash
$ go run . artisan make:controller TaskController
 SUCCESS  Controller created successfully
```

It writes `app/http/controllers/task_controller.go`:

```go title="app/http/controllers/task_controller.go"
package controllers

import (
	"github.com/goravel/framework/contracts/http"
)

type TaskController struct {
	// Dependent services
}

func NewTaskController() *TaskController {
	return &TaskController{
		// Inject services
	}
}

func (r *TaskController) Index(ctx http.Context) http.Response {
	return nil
}
```

> **Gotcha** The name you give becomes the type, and its snake_case form the file name. `make:controller Task` writes `task.go` with a type `Task`, so end the name in `Controller`.

A method takes `ctx http.Context` and returns `http.Response`, like any handler. Replace `Index` with `Store`, and move the create task handler's body into it:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-16} t3:store=/ (Store)[(]/
 package controllers
 
 import (
 	"github.com/goravel/framework/contracts/http"
 )
 
 type TaskController struct {
 	// Dependent services
 }
 
 func NewTaskController() *TaskController {
 	return &TaskController{
 		// Inject services
 	}
 }
 
-func (r *TaskController) Index(ctx http.Context) http.Response {
-	return nil
-}
+func (r *TaskController) Store(ctx http.Context) http.Response {
+	return ctx.Response().String(200, "task created")
+}
```

Controller methods are named after what they do:

| method | route | does |
| --- | --- | --- |
| `Index` | `GET /tasks` | lists them |
| `Show` | `GET /tasks/{id}` | shows one |
| `Store` | `POST /tasks` | creates one |
| `Update` | `PUT /tasks/{id}` | changes one |
| `Destroy` | `DELETE /tasks/{id}` | deletes one |

Point the route at the method:

```diff lang="go" title="routes/api.go" t3:store=/taskController[.](Store)/ t1:route-name="tasks.create"
 package routes
 
 import (
 	"taskflow/app/facades"
+	"taskflow/app/http/controllers"
 
-	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/contracts/route"
 )
 
 func Api() {
+	taskController := controllers.NewTaskController()
+
 	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
-		router.Post("tasks", func(ctx http.Context) http.Response {
-			return ctx.Response().String(200, "task created")
-		}).Name("tasks.create")
+		router.Post("tasks", taskController.Store).Name("tasks.create")
 	})
 }
```

`NewTaskController` runs once, when the routes are registered, so every request shares the one controller. Keep a request's data in local variables, never in the struct's fields.

The `POST` still answers `task created`, and `/hello` now names the method:

```bash
$ curl http://localhost:3000/hello
```

```json title="GET /hello" t1:route-name=/"name": "(tasks.create)"/ t3:store=/[.](Store)-fm/
{
  "handler": "taskflow/app/http/controllers.(*TaskController).Store-fm",
  "method": "POST",
  "name": "tasks.create",
  "path": "/api/v1/tasks"
}
```

Go adds `-fm` to the name of a method used as a value.

Resource controllers: every route in one line:

`make:controller --resource TaskController` writes all five methods from the table, and `router.Resource("tasks", taskController)` registers their routes in one line, with `PATCH` for `Update` too. See [resource controllers](https://www.goravel.dev/the-basics/controllers#resource-controllers).

That line is one entry with one name for all six routes, so the create route can't be named `tasks.create` on its own. TaskFlow keeps one line per route.

## Listing routes

To check which routes are registered, run:

```bash t1:route-name=/ (tasks.create) / t3:store=/[)][.](Store)/
$ go run . artisan route:list

  GET|HEAD     / ................................... taskflow/routes.Web.func1
  POST         api/v1/tasks  tasks.create › taskflow/app/http/controllers.(*TaskController).Store
  GET|HEAD     hello ............................... taskflow/routes.Web.func2
  STATIC       public ........................................................
  GET|HEAD     users ... taskflow/app/http/controllers.(*UserController).Index

                                                            Showing [5] routes
```

It prints every registered route with its method, path, name and handler. `route:list` leaves the `-fm` off. Run it when a route does not answer and you do not know why.

TaskFlow now has its first API route, in its own file and handled by a controller. It doesn't read anything the client sends yet.

---

Previous: [01. Installation & Project Architecture](https://devchalk.com/goravel/01-installation-project-architecture.md)
Next: [03. Reading Request Data](https://devchalk.com/goravel/03-reading-request-data.md)
Series: [Goravel](https://devchalk.com/goravel.md)
