Pages and routes
What we'll learn
- How to add pages to our app
- How to to navigate from one page to another
- How to style a page with CSS

How routing works
When we create a new project with elm-land init
, a homepage is automatically created for us. A homepage is a great start, but most web applications have multiple pages!
For example, we might want these four pages in our app:
- Homepage - shows a feed of the latest posts
- Sign in - allow users to sign in with email/password
- Settings - allows a user to change their account settings
- Profile - View the profile of a specific user
Page | URLs | Elm file |
---|---|---|
Homepage | / | src/Pages/Home_.elm |
Sign in | /sign-in | src/Pages/SignIn.elm |
Settings | /settings/account | src/Pages/Settings/Account.elm |
Profile | /profile/ryan /profile/duncan /profile/alexa | src/Pages/Profile/Username_.elm |
In Elm Land, the names of files in our src/Pages
automatically connect a URL to a specific page. For example, if we navigated to /messages
in a web browser, Elm Land would look for a file at src/Pages/Messages.elm
In this guide, we'll learn how to use the Elm Land CLI to add new pages by specifying the URL we want to visit in the browser.
Creating a fresh project
Let's create a new project with the CLI, then run a local development server:
elm-land init pages-and-routes
elm-land init pages-and-routes
cd pages-and-routes
cd pages-and-routes
elm-land server
elm-land server
Now that we have a new Elm Land project, and a server running at http://localhost:1234
we can use the CLI to add a new page.
Static routes
To get started, let's start with a page that is displayed when a user visits the URL /sign-in
.
We can create our sign-in page using the elm-land add page
command shown below:
elm-land add page:static /sign-in
elm-land add page:static /sign-in
🌈 Elm Land added a new page at /sign-in
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
You can edit your new page here:
./src/Pages/SignIn.elm
🌈 Elm Land added a new page at /sign-in
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
You can edit your new page here:
./src/Pages/SignIn.elm
2
3
4
The elm-land add page:static
command created a view-only page that allows us to customize two things:
title
- the text shown in the browser tabbody
- the HTML we want to render on the screen
Here's what that code looks like for our /sign-in
page
module Pages.SignIn exposing (page)
import Html
import View exposing (View)
page : View msg
page =
{ title = "Pages.SignIn"
, body = [ Html.text "/sign-in" ]
}
module Pages.SignIn exposing (page)
import Html
import View exposing (View)
page : View msg
page =
{ title = "Pages.SignIn"
, body = [ Html.text "/sign-in" ]
}
2
3
4
5
6
7
8
9
10
11
Anytime you run the elm-land add page
command, a new file will be created in the src/Pages
folder.
If we visit http://localhost:1234/sign-in
in the browser, we will see this new page:
Nested routes
Some pages in our app need a URL like /settings/account
or /settings/notifications
. In Elm Land, we refer to these as "nested routes".
A nested route is what we call a route with more than one slash in the URL. Let's add a nested route for account settings:
elm-land add page:static /settings/account
elm-land add page:static /settings/account
🌈 Elm Land added a new page at /settings/account
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
You can edit your new page here:
./src/Pages/Settings/Account.elm
🌈 Elm Land added a new page at /settings/account
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
You can edit your new page here:
./src/Pages/Settings/Account.elm
2
3
4
Here is the code generated at ./src/Pages/Settings/Account.elm
:
module Pages.Settings.Account exposing (page)
import Html
import View exposing (View)
page : View msg
page =
{ title = "Pages.Settings.Account"
, body = [ Html.text "/settings/account" ]
}
module Pages.Settings.Account exposing (page)
import Html
import View exposing (View)
page : View msg
page =
{ title = "Pages.Settings.Account"
, body = [ Html.text "/settings/account" ]
}
2
3
4
5
6
7
8
9
10
11
This is what we see when we visit http://localhost:1234/settings/account
"How deep can I nest routes?"
You can nest routes as much as you like, it doesn't have to be only two-levels deep:
elm-land add page:static /something/really/nested/like/super/nested
elm-land add page:static /something/really/nested/like/super/nested
That command will create a file in a bunch of nested folders inside your src/Pages
directory, and be available when visiting the provided URL.
Dynamic routes
For things like our "Profile" page, we won't know all the usernames up-front. It's common to define a single detail page that will work for any username provided in the URL.
When we need a page to handle URLs like /profile/ryan
, /profile/duncan
, or /profile/alexa
, we can make a "dynamic route".
A dynamic route passes in URL parameters (like username
) to your page as an input, so it can handle the dynamic values.
elm-land add page:static /profile/:username
elm-land add page:static /profile/:username
Unlike our static /sign-in
and /settings/account
pages, the dynamic profile page has access to a URL parameter input. Let's take a look at the new file together:
module Pages.Profile.Username_ exposing (page)
import Html
import View exposing (View)
page : { username : String } -> View msg
page params =
{ title = "Pages.Profile.Username_"
, body = [ Html.text ("/profile/" ++ params.username) ]
}
module Pages.Profile.Username_ exposing (page)
import Html
import View exposing (View)
page : { username : String } -> View msg
page params =
{ title = "Pages.Profile.Username_"
, body = [ Html.text ("/profile/" ++ params.username) ]
}
2
3
4
5
6
7
8
9
10
11
Here, the value of params.username
depends on the URL in the browser. For example, when a user navigates to /profile/ryan
, the value of params.username
will be "ryan"
.
This will be helpful later, when we learn how to work with APIs to fetch different content based on URL parameters.
Names for dynamic parameters
We learned that names of page files affect which URL renders our page, but they also can affect the names of our URL parameters.
Because our profile page was at Profile/Username_.elm
, the value for our URL parameter is params.username
.
If we renamed this file to Profile/Id_.elm
, it would automatically update the parameter name to params.id
. The Elm compiler will let us know if any of our code needs to change, so this isn't a scary thing!
This allows us the flexibility to choose the name that makes sense in each specific scenario.
"What about numeric IDs?"
Many apps have URLs like /posts/123
, which use Int
IDs to work with backend APIs. Elm Land supports these URLs too!
If we made a page at src/Pages/Posts/Id_.elm
, and visited /posts/123
, this would be the value of our URL parameters:
params.id == "123"
params.id == "123"
Notice how "123"
is a String
, not an Int
?
Elm Land treats all URL parameters as String
values. In practice, whether we get a URL like /posts/99999
or /posts/banana
, we'll still show the same "post not found" view in our app.
Having the "type safety" of Int
doesn't buy us much in this scenario. This design choice is also consistent with other popular JS frameworks like Vue's Nuxt.js and React's Next.js.
"What's up with the trailing underscores?"
You may have noticed there is a trailing underscore in some of our filenames. What's up with that?
Here are some reasons you will see an underscore in page filenames:
- To distinguish
/
(Home_.elm
) from/home
(Home.elm
) - To distinguish a static page from a dynamic one:
Profile/Username.elm
only handles/profile/username
Profile/Username_.elm
handles/profile/ryan
,profile/duncan
, and more!
Catch-all routes
Some web applications have pages that need to respond to many different URLs with an unknown number of /
characters between them.
A popular example of this is GitHub's code explorer page, which needs to handle a pattern like this:
/:owner/:repo/tree/:branchName/*
/:owner/:repo/tree/:branchName/*
There will always be an owner
, repo
, and branch
name– but the number of files in a repo could mean any length of URL. It depends on the content of the project's repo.
Here are some real URL examples to help you visualize how the depth of this page's URL could be any length:
/elm/compiler/tree/master/README.md
/elm-land/elm-land/tree/main/docs/README.md
/elm-land/elm-land/tree/main/examples/01-hello-world/elm.json
/elm-land/elm-land/tree/main/examples/02-pages-and-routes
/elm/compiler/tree/master/README.md
/elm-land/elm-land/tree/main/docs/README.md
/elm-land/elm-land/tree/main/examples/01-hello-world/elm.json
/elm-land/elm-land/tree/main/examples/02-pages-and-routes
2
3
4
Adding a catch-all route
Luckily, Elm Land supports creating pages like this! Let's use the elm-land add page
CLI command to create a "catch-all route" that matches deeply nested URL patterns.
For simplicity, let's do one that matches /blog/*
:
elm-land add page:static '/blog/*'
elm-land add page:static '/blog/*'
This will create a brand new file at src/Pages/Blog/ALL_.elm
:
module Pages.Blog.ALL_ exposing (page)
import Html exposing (Html)
import View exposing (View)
page : { first_ : String, rest_ : List String } -> View msg
page params =
{ title = "Pages.Blog.ALL_"
, body =
[ Html.text
("/blog/" ++
String.join "/" (params.first_ :: params.rest_)
)
]
}
module Pages.Blog.ALL_ exposing (page)
import Html exposing (Html)
import View exposing (View)
page : { first_ : String, rest_ : List String } -> View msg
page params =
{ title = "Pages.Blog.ALL_"
, body =
[ Html.text
("/blog/" ++
String.join "/" (params.first_ :: params.rest_)
)
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Just like we saw before with dynamic routes, the trailing _
in this filename means this page does something special. In our case, the ALL_.elm
filename is a reserved keyword for a "catch-all route".
Try opening any of these URLs in our browser:
http://localhost:1234/blog/hello
http://localhost:1234/blog/elm/land
http://localhost:1234/blog/elm/land/ui
All of those URLs will match our single page file.
Understanding catch-all parameters
When working with catch-all routes, you'll have access to two special URL parameters in route.params
:
first_ : String
– The first URL parameter in the catch-all routerest_ : List String
– The remaining URL parameters
Here's a visual of the URL parameters you'll get for the URLs we listed above:
URL | route.params |
---|---|
/blog/hello | { first_ = "hello", rest_ = [] } |
/blog/elm/land | { first_ = "elm", rest_ = [ "land" ] } |
/blog/elm/land/ui | { first_ = "elm", rest_ = [ "land", "ui" ] } |
"Why not just one List String
?"
Elm Land provides the URL parameters in two separate variables so you don't need to worry about handling the case where your list is empty.
This is another way to represent a "non-empty list", which is a popular data structure for guaranteeing that you don't have to handle an edge case for an impossible URL!
Our project so far
After adding in all these pages, our project should look something like this:
elm.json
elm-land.json
src/
|- Pages/
|- Home_.elm
|- SignIn.elm
|- Blog/
|- ALL_.elm
|- Settings/
|- Account.elm
|- Profile/
|- Username_.elm
elm.json
elm-land.json
src/
|- Pages/
|- Home_.elm
|- SignIn.elm
|- Blog/
|- ALL_.elm
|- Settings/
|- Account.elm
|- Profile/
|- Username_.elm
2
3
4
5
6
7
8
9
10
11
12
If you are ever curious about the routes in your Elm application, you can use the built-in elm-land routes
command. Here's what that looks like:
elm-land routes
elm-land routes
🌈 Elm Land (v0.18.1) found 5 pages in your application
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
src/Pages/Home_.elm ............... http://localhost:1234/
src/Pages/SignIn.elm .............. http://localhost:1234/sign-in
src/Pages/Blog/ALL_.elm ........... http://localhost:1234/blog/*
src/Pages/Settings/Account.elm .... http://localhost:1234/settings/account
src/Pages/Profile/Username_.elm ... http://localhost:1234/profile/:username
🌈 Elm Land (v0.18.1) found 5 pages in your application
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
src/Pages/Home_.elm ............... http://localhost:1234/
src/Pages/SignIn.elm .............. http://localhost:1234/sign-in
src/Pages/Blog/ALL_.elm ........... http://localhost:1234/blog/*
src/Pages/Settings/Account.elm .... http://localhost:1234/settings/account
src/Pages/Profile/Username_.elm ... http://localhost:1234/profile/:username
2
3
4
5
6
7
8
9
Adding a sidebar
So far, to navigate from one page to another, we've been manually changing the URL in the browser. In a real app, our users need a way to navigate the app within the UI.
For that reason, let's make a sidebar component with convenient links to the "Homepage", "Account Settings", and "Profile" pages. We'll design our component so it's easy to add it to any page we like!
Let's create a new file at src/Components/Sidebar.elm
:
module Components.Sidebar exposing (view)
import Html exposing (Html)
import Html.Attributes as Attr
import View exposing (View)
view : { page : View msg } -> View msg
view { page } =
{ title = page.title
, body =
[ Html.div [ Attr.class "page" ] page.body
]
}
module Components.Sidebar exposing (view)
import Html exposing (Html)
import Html.Attributes as Attr
import View exposing (View)
view : { page : View msg } -> View msg
view { page } =
{ title = page.title
, body =
[ Html.div [ Attr.class "page" ] page.body
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
To make it easier to use, we'll accept the entire page as the input to this UI component. If you're familiar with Vue.js, this ideas is similar to their notion of "slots". Just like we might pass in a String
, Int
, or another value, we can pass in an entire View msg
to allow page's to be nested within a component.
In the default example, we are wrapping the page's content in a div
with the class "page"
Adding some more HTML
The default layout doesn't have a sidebar, but we can make one with some HTML. Add the highlighted lines below to your new src/Components/Sidebar.elm
file
module Components.Sidebar exposing (view)
import Html exposing (Html)
import Html.Attributes as Attr
import View exposing (View)
view : { page : View msg } -> View msg
view { page } =
{ title = page.title
, body =
[ Html.div [ Attr.class "layout" ]
[ viewSidebar
, Html.div [ Attr.class "page" ] page.body
]
]
}
viewSidebar : Html msg
viewSidebar =
Html.aside [ Attr.class "sidebar" ]
[ Html.a [ Attr.href "/" ] [ Html.text "Home" ]
, Html.a [ Attr.href "/profile/me" ] [ Html.text "Profile" ]
, Html.a [ Attr.href "/settings/account" ] [ Html.text "Settings" ]
]
module Components.Sidebar exposing (view)
import Html exposing (Html)
import Html.Attributes as Attr
import View exposing (View)
view : { page : View msg } -> View msg
view { page } =
{ title = page.title
, body =
[ Html.div [ Attr.class "layout" ]
[ viewSidebar
, Html.div [ Attr.class "page" ] page.body
]
]
}
viewSidebar : Html msg
viewSidebar =
Html.aside [ Attr.class "sidebar" ]
[ Html.a [ Attr.href "/" ] [ Html.text "Home" ]
, Html.a [ Attr.href "/profile/me" ] [ Html.text "Profile" ]
, Html.a [ Attr.href "/settings/account" ] [ Html.text "Settings" ]
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Next, we'll actually use this new sidebar component in our pages.
Adding a component to a page
This new sidebar isn't automatically wired up to all our pages. In Elm Land, you can easily opt-in to which pages should use the sidebar by importing the module.
For our example, we don't want a sidebar on the "Sign in" page. For that reason, we will only connect it to our "Homepage", "Account Settings", and "Profile" page by adding in these lines of code:
module Pages.Home_ exposing (page)
import Components.Sidebar
import Html
import View exposing (View)
page : View msg
page =
Components.Sidebar.view
{ page =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]
}
}
module Pages.Home_ exposing (page)
import Components.Sidebar
import Html
import View exposing (View)
page : View msg
page =
Components.Sidebar.view
{ page =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Here's what we did in the code snippet above:
- Imported the
Components.Sidebar
module on line 3 - Passed in the previous
{ title, body }
record as an input to our component
Try following the same steps to get this working for: Pages.Settings.Account
and Pages.Profile.Username_
. I've included the actual code snippets when you're ready to see what's changed:
Adding the sidebar to Pages.Settings.Account
module Pages.Settings.Account exposing (page)
import Components.Sidebar
import Html
import View exposing (View)
page : View msg
page =
Components.Sidebar.view
{ page =
{ title = "Pages.Settings.Account"
, body = [ Html.text "/settings/account" ]
}
}
module Pages.Settings.Account exposing (page)
import Components.Sidebar
import Html
import View exposing (View)
page : View msg
page =
Components.Sidebar.view
{ page =
{ title = "Pages.Settings.Account"
, body = [ Html.text "/settings/account" ]
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Adding the sidebar to Pages.Profile.Username_
module Pages.Profile.Username_ exposing (page)
import Components.Sidebar
import Html
import View exposing (View)
page : { username : String } -> View msg
page params =
Components.Sidebar.view
{ page =
{ title = "Pages.Profile.Username_"
, body = [ Html.text ("/profile/" ++ params.username) ]
}
}
module Pages.Profile.Username_ exposing (page)
import Components.Sidebar
import Html
import View exposing (View)
page : { username : String } -> View msg
page params =
Components.Sidebar.view
{ page =
{ title = "Pages.Profile.Username_"
, body = [ Html.text ("/profile/" ++ params.username) ]
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Styling things with CSS
All of our pages and layouts are ready, but there's still one missing piece: the page doesn't look pretty. We can add CSS to our Elm Land projects by modifying the elm-land.json
file at the root of our project.
Let's add a <link>
tag to our HTML by updating the app.html.link
property:
{
"app": {
"elm": {
"development": { "debugger": true },
"production": { "debugger": false }
},
"env": [],
"html": {
"attributes": {
"html": { "lang": "en" },
"head": {}
},
"title": "My Elm Land App",
"meta": [
{ "charset": "UTF-8" },
{ "http-equiv": "X-UA-Compatible", "content": "IE=edge" },
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0" }
],
"link": [
{ "rel": "stylesheet", "href": "/styles.css" }
],
"script": []
},
"router": {
"useHashRouting": false
}
}
}
{
"app": {
"elm": {
"development": { "debugger": true },
"production": { "debugger": false }
},
"env": [],
"html": {
"attributes": {
"html": { "lang": "en" },
"head": {}
},
"title": "My Elm Land App",
"meta": [
{ "charset": "UTF-8" },
{ "http-equiv": "X-UA-Compatible", "content": "IE=edge" },
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0" }
],
"link": [
{ "rel": "stylesheet", "href": "/styles.css" }
],
"script": []
},
"router": {
"useHashRouting": false
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
You can serve static files like images or CSS by adding them in a static
folder at the project root, alongside the src
folder and elm-land.json
file.
For this example, let's create a new CSS file at ./static/styles.css
, with the following CSS:
body {
padding: 32px;
}
.layout {
display: flex;
gap: 16px;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 8px;
}
body {
padding: 32px;
}
.layout {
display: flex;
gap: 16px;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 8px;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
Now that we've added in some CSS, we should see our full example working. We can use our sidebar to navigate from one page to another.

See the full example in the examples/02-pages-and-routes folder on GitHub.
Congratulations! 🎉
You just made a multi-page application in Elm Land!
Next up, let's take a look at how we can handle user input using The Elm Architecture!