---
title: Routing
description: How the route table maps requests to controller actions, groups routes into scopes and pipelines, and generates path helpers that are checked when the app compiles.
category: Views and Controllers
order: 1
---
The router decides which controller action answers a request. In Glacier
every route is declared once, in `src/routes.glacier`, in a `routes do`
block. From that one table Glacier builds the router the app runs and a
`*_path()` function for every named route, so a link to a route that
doesn't exist is a compile error rather than a broken page.
After reading this guide, you will know:
- How to declare routes with `get`, `post`, `patch`, `put` and `delete`.
- What `resources` gives you, and how to trim it with `only`.
- How scopes add path prefixes and pipelines add middleware to groups of routes.
- How path parameters, suffixes and globs are captured, and which route wins when several match.
- How the generated path helpers work, and how records know their own URLs.
- What the router does by itself: `HEAD`, `405 Method Not Allowed`, method override and the `/up` health check.
- Which routing features Glacier doesn't have yet.
The examples come from `notes`, a small app made with `glacier new notes`
and `glacier generate scaffold Note title:string body:text`, then extended
for this guide and the next two.
## The Route Table
`glacier new` writes `src/routes.glacier` with one pipeline and one scope.
Here is the `notes` app's table once this guide's routes are added:
```glacier
routes do
alias Middleware.NoIndex
alias Middleware.RequireAdmin
alias Middleware.AdminCounts
alias glacier.middleware.BasicAuth
# HTML pages: security headers + CSRF protection.
pipeline browser = Pipeline.browser()
# Only for people who have unlocked the admin area.
pipeline admin = Pipeline.new().with(RequireAdmin).with(NoIndex).with(AdminCounts)
# For the people running the app: a name and password, asked for by
# the browser (`[ops]` in the config, or OPS_PASSWORD).
pipeline ops = Pipeline.new().with(BasicAuth.from_config("ops")).with(NoIndex)
scope "/", through: :browser do
get "/", PagesController.home, as: :home
resources "/notes", NotesController
get "/notes/:id/preview", NotesController.preview, as: :preview_note
get "/notes/:id.json", NotesController.export, as: :note_json
post "/notes/:id/pin", NotesController.pin, as: :pin_note
delete "/notes/:id/pin", NotesController.unpin
delete "/notes", NotesController.destroy_many
resources "/docs", DocsController, only: [:index, :show]
get "/attachments/*path", AttachmentsController.show, as: :attachment
resources "/guides", DocsController, only: [:show] do
resources "/sections", SectionsController, only: [:index, :show]
end
get "/downloads/:platform", DownloadsController.show, where: [platform: "mac|linux|windows"]
get "/settings", SettingsController.show, as: :settings
patch "/settings", SettingsController.update
post "/admin/session", AdminController.unlock, as: :admin_session
delete "/admin/session", AdminController.lock
end
scope "/admin", through: [:browser, :admin] do
get "/", AdminController.dashboard, as: :admin
end
scope "/ops", through: :ops do
get "/", AdminController.ops, as: :ops
end
end
```
Each line is a route, a scope or a pipeline; `do … end` groups routes
under a scope or a resource. The table compiles to:
- `draw()`, which the app passes to `App::routes` to build the router;
- a `pub fn …_path(…) -> String` for each named route.
The handler after the path is anything that can answer a request.
Usually it's a controller action, `NotesController.preview`, which names
the `preview` action of `NotesController` in
`src/controllers/notes_controller.glacier`. Controllers are covered in
[Controllers](controllers.html).
NOTE: The table sees every controller, which is what lets it say `Notes`
rather than `Controllers.Notes`. Anything else, such as the middleware
this app uses, needs an `alias` inside the block.
## Verb Routes
A route is an HTTP method, a path and a handler:
```glacier
get "/settings", SettingsController.show, as: :settings
patch "/settings", SettingsController.update
```
The methods are `get`, `post`, `put`, `patch` and `delete`. Anything else
is a compile error.
Adding `as: :name` names the route and generates a helper called
`name_path`. Here `settings_path()` returns `"/settings"`. The `patch`
route shares the path, so it needs no name of its own: a form that sends
`PATCH` to `settings_path()` reaches it. Names must be unique across the
whole table; using one twice is a compile error that tells you to pick a
different `as:` name.
`glacier generate controller` adds verb routes for you. Each action gets a
`get` route named after the controller and action, appended to the
`scope "/", through: :browser` block:
```text
$ glacier generate controller Admin dashboard
create src/controllers/admin_controller.glacier
create src/views/admin/dashboard.html
create tests/controllers/admin_controller.test.glacier
update tests/glacier.rs
update src/routes.glacier
Next: visit /admin/dashboard
```
That added `get "/admin/dashboard", AdminController.dashboard, as: :admin_dashboard`.
The `notes` app then moved the dashboard into its own scope, as shown
below.
## Resources
`resources` declares the conventional routes for a collection in one line:
```glacier
resources "/notes", NotesController
get "/notes/:id/preview", NotesController.preview, as: :preview_note
get "/notes/:id.json", NotesController.export, as: :note_json
post "/notes/:id/pin", NotesController.pin, as: :pin_note
delete "/notes/:id/pin", NotesController.unpin
delete "/notes", NotesController.destroy_many
```
`resources "/notes", NotesController` gives these seven routes, and these helpers:
| `GET` | `/notes` | `index` | `notes_path()` |
| `GET` | `/notes/new` | `new` | `new_note_path()` |
| `POST` | `/notes` | `create` | `notes_path()` |
| `GET` | `/notes/:id` | `show` | `note_path(id)` |
| `GET` | `/notes/:id/edit` | `edit` | `edit_note_path(id)` |
| `PATCH` and `PUT` | `/notes/:id` | `update` | `note_path(id)` |
| `DELETE` | `/notes/:id` | `destroy` | `note_path(id)` |
The helper names come from the last segment of the path: `notes` for the
collection, and its singular, `note`, for one record. `as` gives the
singular helpers another name: `resources "/people", PeopleController, as: :member`
gives `member_path(id)`, `new_member_path()` and `edit_member_path(id)`. The collection helper
always takes the path's last segment, so two `resources` whose paths end
in the same word would both define it, which is a compile error.
A controller only needs the actions it uses. The router mounts the
standard actions the controller defines, and skips the rest. The helpers
don't know which actions exist, though: without `only`, a resource gets
helpers for all seven, so `edit_note_path(id)` would compile even if
`Notes` had no `edit`, and the link would answer `404`.
### Choosing Actions with `only:`
`only: [...]` mounts just the listed actions, and generates just their
helpers:
```glacier
resources "/docs", DocsController, only: [:index, :show]
get "/attachments/*path", AttachmentsController.show, as: :attachment
```
`Docs` has `index` and `show`, so the app has `GET /docs` and
`GET /docs/:id`, with `docs_path()` and `doc_path(id)`. Nothing else is
routed:
```elixir
test "has only the actions it was given" do
assert get("/docs/routing/edit").status == :not_found
res = post "/docs"
assert res.status == :method_not_allowed
end
```
The names in `only:` must be among `:index`, `:new`, `:create`, `:show`,
`:edit`, `:update` and `:destroy`; anything else is a compile error.
The `:id` of a resource is just a path parameter, and doesn't have to be a
number, though it can't contain a `.` (see [Format
Extensions](#format-extensions)). `Docs` looks its pages up by name, so
`/docs/routing` is the routing page.
### Extra Routes for a Resource
An extra route on a record's own `:id` is an ordinary verb route next to
the resource, as `preview`, `export`
and `pin` are in the `notes` block above. Since `preview` is named
`preview_note`, `preview_note_path(note.id)` builds its path.
`destroy_many`, which deletes several notes at once (see
[Controllers](controllers.html#lists-with-list)), has no name of its own:
its path is `/notes`, which `notes_path()` already builds.
### Nested Resources
A `resources` line can open a block (`do … end`) of routes that belong
under one of its records. They go under `/<path>/:<singular>_id`, with the parameter named
after the outer path's last segment in the singular (`guide_id` for
`/guides`), and their helpers start with that singular:
```glacier
resources "/guides", DocsController, only: [:show] do
resources "/sections", SectionsController, only: [:index, :show]
end
```
```elixir
def index(ctx) do
json [guide: ctx.param("guide_id").unwrap_or_default()]
end
def show(ctx) do
json [guide: ctx.param("guide_id").unwrap_or_default(), section: ctx.param("id").unwrap_or_default()]
end
```
```elixir
test "nests sections under a guide" do
assert guide_sections_path("routing") == "/guides/routing/sections"
assert guide_section_path("routing", "intro") == "/guides/routing/sections/intro"
res = get guide_section_path("routing", "intro")
assert res.body == "{\"guide\":\"routing\",\"section\":\"intro\"}"
end
```
A block can hold verb routes too. They also go under
`/<path>/:<singular>_id`, so a route on the record's own `:id` is still
written beside the resource.
## Scopes and Pipelines
### Scopes
`scope "/prefix" do … end` puts its routes under a path prefix. Scopes can
nest, and a route's path is joined to its scope's, so inside
`scope "/admin"`, `get "/"` is `/admin`:
```glacier
scope "/admin", through: [:browser, :admin] do
get "/", AdminController.dashboard, as: :admin
end
```
The helper's path includes the prefix: `admin_path()` returns `"/admin"`.
A scope's routes don't get a name prefix or a module prefix; the names and
controllers are exactly what you write.
### Pipelines
A pipeline is a named stack of middleware that runs for the routes of the
scopes that go `through` it. Pipelines are
declared at the top of the table, outside any scope:
```glacier
# HTML pages: security headers + CSRF protection.
pipeline browser = Pipeline.browser()
# Only for people who have unlocked the admin area.
pipeline admin = Pipeline.new().with(RequireAdmin).with(NoIndex).with(AdminCounts)
```
Glacier provides two:
- `Pipeline.browser()`, for HTML pages: `SecureHeaders`, which adds
`X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy: strict-origin-when-cross-origin` and
`Cross-Origin-Opener-Policy: same-origin` (unless the action already set
them), then `Csrf`, which refuses a `POST`, `PUT`, `PATCH` or `DELETE`
without the session's CSRF token.
- `Pipeline.api()`, for JSON APIs: `JsonErrors`, which turns error
responses into JSON. See [JSON APIs](json_apis.html).
`Pipeline.new().with(Middleware)` builds your own; `.with` can be chained
to add more, and adds to Glacier's two as well:
`Pipeline.api().with(glacier::middleware::VerifyAuthorized)` also checks
that every action authorizes. Its place in a pipeline matters; see
[Authentication and Authorisation](authentication.html#authorisation). The `admin` pipeline above holds the app's own
middleware, `RequireAdmin` and `NoIndex`, from `src/middleware/`:
```glacier
# Lets a request through only if this session has unlocked the admin
# area; anyone else goes back to the home page with an alert.
module RequireAdmin do
implements Middleware
def call(ctx, next) do
if session.get::<bool>("admin") == Some(true) do
return next(ctx)
end
redirect home_path(), alert: "Admins only."
end
end
```
A scope can go through several pipelines, in a list:
`scope "/admin", through: [:browser, :admin]`. They run in the order listed, so
here the CSRF check runs before `RequireAdmin`. Naming a pipeline that
wasn't declared is a compile error.
Anyone who hasn't unlocked the admin area is sent back:
```elixir
test "sends visitors back with an alert" do
res = get "/admin"
assert res.status == :see_other
assert res.location == "/"
assert get("/").body =~ "Admins only."
end
```
NOTE: Pipelines run after routing, so they only see requests that matched
one of their routes. They never see a request that matches no route (the
router's own `404`, or a `405`), though they do see errors their actions
return, such as a `404` from `find`. Middleware that should see
every request, such as the logger, is one of the app's settings, in
`src/app.glacier` (`middleware Logger`). Writing middleware is covered in
[Controllers](controllers.html#middleware-for-cross-cutting-concerns).
## Path Parameters
A segment starting with `:` captures that part of the path under its name,
up to the next `/` or `.`. `/notes/:id` matches `/notes/42` with `id` set
to `"42"`. Controllers read
it as `params[:id]` (`Note.find(params[:id])!` answers `404 Not Found`
when it isn't a number), or as a number with `ctx.id()`, which answers
404 too. Captured values are percent-decoded, so `/docs/hello%20world`
gives `"hello world"`.
A parameter can end in a fixed extension that starts with `.`:
`/notes/:id.json` matches `/notes/7.json`
with `id` set to `"7"`, so `ctx.id()` works as usual, here inside
`find_note` (`fresh_when` is covered in
[Controllers](controllers.html#conditional-gets-with-fresh-when)):
```elixir
def export(ctx) do
note = find_note(&ctx)!
res = json note
res?.fresh_when(&ctx, note.updated_at)
end
```
A segment starting with `*` is a glob: it captures the rest of the path,
slashes and all. It must be the last segment, and must match at least one
segment, so `/attachments/*path` doesn't match `/attachments` itself.
```elixir
test "captures the rest of the path in a glob" do
res = get "/attachments/reports/2026/q1.pdf"
assert res.body == "<p>You asked for reports/2026/q1.pdf</p>"
assert attachment_path("reports/q1 draft.pdf") == "/attachments/reports/q1%20draft.pdf"
end
```
A trailing slash is ignored: `/notes/` is routed as `/notes`.
### Format Extensions
Every route also takes an optional extension. A
path that no route matches is tried again without its extension, which
becomes the `format` param: `/docs/routing.md` reaches `Docs#show` with
`id` set to `"routing"` and `ctx.param("format")` set to `"md"`. The extension must
start with a letter and be letters and digits only, so in `/docs/v1.2` the
`.2` isn't a format and nothing matches.
A route that matches the whole path wins over splitting off a format,
which is why `/notes/7.json` reaches the `export` route above rather than
`show`. A glob keeps its dots, as the glob test above shows with
`reports/2026/q1.pdf`.
```elixir
test "takes an optional format extension" do
# /docs/:id has no extension, so `.md` is split off as the format.
res = get "/docs/routing.md"
assert res.status == :ok
assert res.body =~ "Routes live in"
end
test "stops a parameter at a dot" do
# `2` isn't a format (it doesn't start with a letter), so nothing matches.
assert get("/docs/v1.2").status == :not_found
end
test "doesn't round-trip an id with a dot" do
assert doc_path("routing.md") == "/docs/routing.md"
# …which reaches the `routing` doc, not one called `routing.md`.
assert get(doc_path("routing.md")).body =~ "Routes live in"
end
```
### Constraints
`where:` restricts what a parameter can match, with a regular expression
that must match the whole parameter. A request whose parameter doesn't
match isn't routed there; it's tried against the later routes, and gets a
`404` if none matches:
```glacier
get "/downloads/:platform", DownloadsController.show, where: [platform: "mac|linux|windows"]
```
```elixir
test "matches only the platforms the constraint allows" do
assert get("/downloads/mac").body == "{\"platform\":\"mac\"}"
assert get("/downloads/beos").status == :not_found
# The pattern must match the whole parameter.
assert get("/downloads/macos").status == :not_found
end
```
A pattern that isn't a valid regular expression, or a `where:` for a
parameter the path doesn't have, is a build error.
### Which Route Wins
When more than one route matches a path, the most specific wins, compared
segment by segment from the left: a fixed segment beats a parameter with a
suffix, which beats a plain parameter, which beats a glob. If two routes
are equally specific, the one declared first wins. The order of the table
doesn't otherwise matter, so `/notes/new` reaches `new` rather than `show`,
even though `show` is registered first (see the listing in
[Listing Routes](#listing-routes)):
```elixir
test "a static segment beats a parameter" do
assert get("/notes/new").body =~ "<h1>New note</h1>"
end
```
and `/notes/7.json` reaches `export` rather than `show`.
## Path Helpers
Every named route, and every `resources`, defines a function that builds
its path. Each parameter in the pattern becomes an argument, in order,
that takes anything printable (`impl Display`), so `note_path(7)` and
`note_path(note.id)` both work:
```elixir
test "builds paths from the route table" do
assert home_path() == "/"
assert notes_path() == "/notes"
assert new_note_path() == "/notes/new"
assert note_path(7) == "/notes/7"
assert edit_note_path(7) == "/notes/7/edit"
assert preview_note_path(7) == "/notes/7/preview"
assert note_json_path(7) == "/notes/7.json"
assert admin_path() == "/admin"
end
```
Values are percent-encoded for the URL; a glob's argument keeps its
slashes, as the glob test above shows. A `.` isn't encoded, so a value with
a dot doesn't round-trip through a `:param` route: `doc_path("routing.md")`
builds `/docs/routing.md`, which reaches the `routing` doc with format
`md`, as the last format test shows.
The helpers are visible in controllers (which import `crate::routes::*`)
and templates, and in
`.glacier` tests with `alias Routes.note_path`. Because they're ordinary
functions, a link to a route that doesn't exist, or with the wrong number
of parameters, fails to compile. Misspelling `edit_note_path` in a
template stops the build with:
```text
error[E0425]: cannot find function `edit_notes_path` in this scope
--> src/lib.rs:3:1
|
|
::: src/routes.glacier:5:1
|
5 | routes do
| - similarly named function `edit_note_path` defined here
```
### Records Know Their URL
A model record has a path of its own, `/notes/7`, and the model a
collection path, `/notes`:
```elixir
test "records know their paths" do
note = insert(Note)
assert note.to_path() == format!("/notes/{}", note.id)
assert Note.collection_path() == "/notes"
end
```
So `redirect note`, `link_to("Show", note)` and `get note` in a test all
go to the record's page, and `Note.collection_path()` to the list. These
come from the model's table name rather than the route table, so they
aren't checked against your routes: they match `resources "/notes"`
because the table is `notes`.
### Query Params (new)
Arguments by name after the route's own become the query string, each
value percent-encoded:
```elixir
test "query params by name" do
assert notes_path(page: 2) == "/notes?page=2"
assert note_path(7, tab: :history, q: "a b") == "/notes/7?tab=history&q=a%20b"
end
```
So a controller sends someone back with their address filled in as
`redirect new_session_path(email_address: email)`. An atom is its name,
as `:history` is above.
## What the Router Does for You
### `HEAD` and `405`
A `HEAD` request is answered by the matching `GET` route, without a body.
When a path matches but the method doesn't, the answer is `405 Method Not
Allowed`, with an `Allow` header listing the methods the path does have:
```elixir
test "answers 405 for a method the path doesn't have" do
res = delete "/settings"
assert res.status == :method_not_allowed
assert res.headers["allow"] == "GET, PATCH"
end
```
A path that matches no route is `404 Not Found`.
### Method Override
HTML forms can only send `GET` and `POST`. A `POST` with a
form field `_method` set to `patch`, `put` or `delete` is treated as that
method. This is done by the `MethodOverride` middleware, which `glacier
new` adds to the app's settings in `src/app.glacier`. The form helpers
`form_for` and `form_tag` add the field for you (see
[Views and Templates](views.html)):
```elixir
test "deletes through a form's _method field" do
note = insert(Note)
res = post note, _method: "delete"
assert res.status == :see_other
assert get(note).status == :not_found
end
```
### The `/up` Health Check
Every app answers `GET /up` (and `HEAD /up`) with `200 ok` when it's
running and its database answers within two seconds, and `503 database
unavailable` when it doesn't, so a load balancer or deploy tool can tell
when to send it traffic:
```elixir
test "answers the health check at /up" do
res = get "/up"
assert res.status == :ok
assert res.body == "ok"
end
```
It runs before any middleware, so it isn't logged and sets no cookies. A
route of your own at `/up` takes its place; `App::health_check("/healthz")`
moves it and `App::without_health_check()` turns it off.
## Listing Routes
`glacier routes` prints the table the app is actually running: method,
path and handler, in the order they were declared.
```text
$ glacier routes
GET / Pages#home
GET /notes Notes#index
GET /notes/:id Notes#show
GET /notes/new Notes#new
POST /notes Notes#create
GET /notes/:id/edit Notes#edit
PATCH /notes/:id Notes#update
PUT /notes/:id Notes#update
DELETE /notes/:id Notes#destroy
GET /notes/:id/preview Notes#preview
GET /notes/:id.json Notes#export
POST /notes/:id/pin Notes#pin
DELETE /notes Notes#destroy_many
GET /docs Docs#index
GET /docs/:id Docs#show
GET /attachments/*path Attachments#show
GET /settings Settings#show
PATCH /settings Settings#update
POST /admin/session Admin#unlock
DELETE /admin/session Admin#lock
GET /admin Admin#dashboard
```
It builds the app and runs it with a task instead of starting the server,
so it shows exactly what `draw()` registered. The health check isn't in
the table, as it isn't a route.
## What's Not There Yet
These routing features don't exist in Glacier yet:
- **Singular resources.** There's no `resource` for a single, id-less
record; use verb routes, as `/settings` does above.
- **Routes on a record's own `:id` inside a resource's block** (a block's
routes go under `:<singular>_id`), excluding actions with `except:`, and
a `root` shorthand. Use verb routes, `only: [...]`, and `get "/"`.
- **Namespaces with module prefixes, and redirects in the route table.** A
scope only prefixes paths.
- **Named route prefixes.** Every name is written out in full with `as:`.