Routing and navigation: folders are URLs¶
Exercise routing-navigation-annex.pdf · PDF
Try to send a friend a link to your "Maths homework" note. You cannot. NoteBox has one address, localhost:5173, whatever is on screen. Now open a note and press the browser's Back button. There is nothing to go back to.
On the web, the address bar is part of the interface. A screen without an address cannot be linked, bookmarked or returned to. If the user can see it, the user should be able to point at it. In Figma you drew arrows between frames. Here, each frame gets an address and the arrows become links.
What you will learn
- Explain file-based routing: which file answers which URL
- Create a new page by creating a folder with a
+page.svelte - Put what all pages share in
+layout.svelte, and mark the current page - Create a dynamic route with
[id]and read the parameter withpage.params - Navigate with links and, from code, with
goto() - Share state between pages through a module, and handle a URL that matches nothing
- The trap:
page.params.idlooks like a number, but it is always a string
Streets and numbers¶
A URL is a postal address. /about is a street with one house. /notes/2 is house number 2 in Notes Street: one street, many houses, and one architect's plan for all of them. Nobody draws a new plan for every house.
File-based routing means every folder inside
src/routesis a URL, and the+page.svelteinside it is what that URL shows.
| URL | File | Shows |
|---|---|---|
/ |
src/routes/+page.svelte |
The notes: search and cards |
/new |
src/routes/new/+page.svelte |
The form |
/notes/2 |
src/routes/notes/[id]/+page.svelte |
One note. [id] is the house number |
/about |
src/routes/about/+page.svelte |
One heading, one paragraph |
| every URL | src/routes/+layout.svelte |
The header and navigation, around the page |
flowchart LR
L["<b>+layout.svelte</b><br>header + navigation"] --> H["<b>/</b><br>+page.svelte"]
L --> A["<b>/about</b><br>about/+page.svelte"]
L --> N["<b>/new</b><br>new/+page.svelte"]
L --> D["<b>/notes/2</b><br>notes/[id]/+page.svelte<br>one plan, many houses"]
There is nothing to register. The folder is the registration. The folder is called about, and the file inside is always +page.svelte, never about.svelte.
A page in thirty seconds¶
Create the folder src/routes/about/ and this file inside it:
<svelte:head>
<title>About · NoteBox</title>
</svelte:head>
<h1>About NoteBox</h1>
<p>A small notes app built in class with Svelte 5 and SvelteKit.</p>
Type localhost:5173/about in the address bar. It exists. <svelte:head> puts things in the page's <head>: here, the title on the browser tab.
The layout: what every page shares¶
The bar you built in Layout with CSS has to appear on every page, so it moves out of +page.svelte and gains a navigation.
A layout is a frame around every page in its folder and below.
{@render children()}marks where the page goes.
<script>
import '../app.css';
import favicon from '#lib/assets/favicon.svg';
import { page } from '$app/state';
let { children } = $props();
const links = [
{ href: '/', label: 'Notes' },
{ href: '/new', label: 'New note' },
{ href: '/about', label: 'About' }
];
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<header class="bar">
<a class="brand" href="/">NoteBox</a>
<nav aria-label="Main">
{#each links as link (link.href)}
<a href={link.href} aria-current={page.url.pathname === link.href ? 'page' : undefined}>
{link.label}
</a>
{/each}
</nav>
</header>
<main>
{@render children()}
</main>
<style>
.bar {
display: flex;
justify-content: space-between;
align-items: center;
height: 56px;
padding: 0 16px;
border-bottom: 1px solid var(--nb-line);
}
.brand {
font-size: 20px;
font-weight: 700;
color: var(--nb-ink);
text-decoration: none;
}
nav { display: flex; gap: 16px; }
nav a { color: var(--nb-link); text-decoration: none; }
nav a[aria-current='page'] {
font-weight: 700;
color: var(--nb-ink);
text-decoration: underline;
}
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
</style>
Three things are happening here:
- Navigation is a plain
<a href>. There is no special component. Click About with the Network tab open: the page does not reload. SvelteKit intercepts the click and swaps only the page. This is client-side navigation. The Back button still works, and the layout stays, which is why the header does not flicker. page(from$app/state) is reactive information about where you are.page.url.pathnameis the path.aria-current="page"tells assistive technology which link is the current one, and the CSS styles from that same attribute. One source of truth, the same trick asaria-invalidin Forms and validation.
State that several pages need¶
The form goes to /new, and the list stays at /. But the notes live in a variable inside the list page. How does the form page add one? It cannot reach it. State that several pages need must live outside the pages.
A file ending in
.svelte.jsis a JavaScript module where runes work. It is loaded once and shared by every page that imports it.
Move the seed, the localStorage lines and addNote out of the page and into a module:
const seed = [
// the four notes, each with id, title, body, tag, pinned
];
const saved = localStorage.getItem('notebox');
export const notes = $state(saved ? JSON.parse(saved) : seed);
function save() {
localStorage.setItem('notebox', JSON.stringify(notes));
}
export function addNote(data) {
notes.push({ id: String(Date.now()), ...data });
save();
}
export function removeNote(id) {
const index = notes.findIndex((note) => note.id === id);
if (index === -1) return;
notes.splice(index, 1);
save();
}
Two things differ from the page version:
notesis exported asconst. You change its contents and never reassign it.- Saving is a plain function call. An
$effectneeds a component to live in, and a module has none.
Why this is safe here
This works because NoteBox turned server rendering off with export const ssr = false; in src/routes/+layout.js. Without that, localStorage would not exist when the module loads. And on a server, a module is shared by all users at once, so one person's data could leak to another. Keep this pattern for browser-only apps.
The list page's script shrinks to this. In its markup, remove the <aside> with the form and the .layout wrapper, and add back <h1>Notes</h1>:
<script>
import { notes } from '#lib/notes.svelte.js';
let query = $state('');
let visible = $derived(
notes.filter((note) => note.title.toLowerCase().includes(query.toLowerCase()))
);
</script>
And the form gets its own page:
<script>
import { goto } from '$app/navigation';
import NoteForm from '#lib/NoteForm.svelte';
import { addNote } from '#lib/notes.svelte.js';
function save(data) {
addNote(data);
goto('/');
}
</script>
<svelte:head>
<title>New note · NoteBox</title>
</svelte:head>
<div class="narrow">
<h1>New note</h1>
<NoteForm onsave={save} />
</div>
<style>
.narrow { max-width: 358px; }
</style>
goto('/') navigates from code: after a valid save, back to the list, where the new card is waiting. NoteForm did not change by one character. It still just calls onsave.
If notes lose their body after this move, an old localStorage value is hiding the new seed. Delete the key notebox once in DevTools.
Careful: pages are not separate programs
You do not need to pass data in the URL or save it to disk to move between pages. The app keeps running while you navigate.
One plan for every house¶
First, make each card a link. The <li> loses the class, and an <a> takes the card's styles, plus color: var(--nb-ink); text-decoration: none;. The card's CSS already targets .card, but the heading inside is now an <h2>, so change .card h3 to .card h2 too:
<li>
<a class="card" href="/notes/{note.id}">
<h2>{note.title}</h2>
<p>{note.body}</p>
<span class="tag">{note.tag}</span>
</a>
</li>
Click one: 404 Not Found. The address exists in the link, but no file answers it yet.
A dynamic route is a folder whose name is a parameter, written in square brackets. It answers many URLs with one file.
Create the folder src/routes/notes/[id]/, brackets included:
<script>
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { notes, removeNote } from '#lib/notes.svelte.js';
let note = $derived(notes.find((item) => item.id === page.params.id));
function remove() {
removeNote(page.params.id);
goto('/');
}
</script>
<svelte:head>
<title>{note ? note.title : 'Not found'} · NoteBox</title>
</svelte:head>
<p><a href="/">< Notes</a></p>
{#if note}
<article>
<h1>{note.title}</h1>
<p class="tag">{note.tag}{note.pinned ? ' · pinned' : ''}</p>
<p>{note.body}</p>
<button onclick={remove}>Delete</button>
</article>
{:else}
<h1>Note not found</h1>
<p>There is no note with the id "{page.params.id}".</p>
{/if}
Whatever is in the URL where the folder says [id] arrives in page.params.id. It is always a string, and '2' === 2 is false. That is why NoteBox's ids have been strings from the start.
Now look at the wrong address. Type /notes/999: same file, the {:else} branch. Who can type a URL by hand? Anybody. So a dynamic route must always decide what to show when the parameter matches nothing.
Finally, the test from the start of this page. Copy the URL of a note and paste it in a new tab. It opens that note.
Careful: why $derived and not a plain value
The page component is reused when you go from /notes/1 to /notes/2. A value computed once at the top would keep showing note 1. $derived follows page.params.
Link or goto()?¶
A button with goto() may look like a link, but it is not the same.
<a href> |
goto() |
|
|---|---|---|
| Use it for | Going somewhere | Navigation that follows an action, such as a save |
| Open in a new tab, copy the address | Yes | No |
| Announced as a link | Yes | No |
And keep the leading slash in every link. href="../notes/3" is relative to the current folder, so it breaks one level down. Write href="/notes/3".
The special files¶
| File | What it is |
|---|---|
+page.svelte |
The page for the URL of its folder |
+layout.svelte |
A frame around every page in its folder and below |
+layout.js, +page.js |
Options and data for the layout or the page. You already have ssr = false |
+error.svelte |
What to show when something fails (next page) |
+server.js |
Not a page: a URL that answers with data (next page) |
[name]/ |
A folder whose name is a parameter. Read it with page.params.name |
At the end, the tree looks like this:
src/
├ app.css
├ lib/
│ ├ NoteForm.svelte
│ └ notes.svelte.js
└ routes/
├ +layout.js export const ssr = false;
├ +layout.svelte header + navigation
├ +page.svelte /
├ about/+page.svelte /about
├ new/+page.svelte /new
└ notes/[id]/+page.svelte /notes/1, /notes/2, ...
Your turn: /tags/[tag]¶
The exercise in the handout adds one more dynamic route, src/routes/tags/[tag]/+page.svelte:
/tags/schoolshows the heading "Tag: school" and lists the notes with that tag. Each title links to/notes/{id}.- A line such as "2 notes" is derived.
- A tag nobody uses shows "No notes with this tag."
- On the note page, the tag becomes a link to
/tags/{tag}, except fornone.
If something fails, check these first:
| What you see | What to check |
|---|---|
page.params.tag is undefined |
The name between brackets is the key. Is the folder [tag]? |
| The list does not update | You filtered once in a plain const. Use $derived |
| A link works from the list but not from a note | It has no leading slash |
Then do it in your own app. Draw a route map (URL, file, screen) with at least four routes, one of them dynamic. Add a navigation that marks the current page, and a decent screen for a parameter that matches nothing. The pages can be nearly empty for now.
Key takeaways¶
- File-based routing: a folder inside
src/routesis a URL, and its+page.svelteis what the URL shows. There is nothing to register. - A layout wraps every page below it, and
{@render children()}marks where the page goes. - Links are plain
<a href>. SvelteKit turns them into client-side navigation with no reload. - A dynamic route like
notes/[id]is one plan for many houses. Read the parameter withpage.params.id: always a string. - Use a link to go somewhere, and
goto()for navigation that follows an action. - State that several pages need lives in a
.svelte.jsmodule, and a dynamic route must always handle a parameter that matches nothing.
Quick check¶
1. Which file answers the URL /contact in SvelteKit?
-
src/routes/contact.svelte -
src/routes/contact/+page.svelte -
src/contact/+layout.svelte -
src/lib/contact/+page.svelte
In file-based routing the folder is the URL, and the file inside is always +page.svelte. There is no list of routes to edit.
2. Why does the header not flicker when you move between pages of NoteBox?
- Because the browser caches every page as an image
- Because each page draws its own copy of the header
- Because the layout stays and only the page inside it changes
- Because the header is written directly in
app.html
The layout is not redrawn on navigation. SvelteKit intercepts the click on the <a href> and swaps only what {@render children()} shows. This is client-side navigation.
3. The folder is src/routes/products/[sku]/ and the URL is /products/42. What is page.params.sku?
- The number 42
- The string '42'
- The string '/products/42'
- It is undefined until you register it
A route parameter is always a string, and the key is the name between the brackets. Comparing it with the number 42 using === gives false.
4. A note page uses const note = notes.find((item) => item.id === page.params.id). What goes wrong when the user moves from note 1 to note 2?
- The component is reused, so it keeps showing note 1
- The browser reloads, so every note in memory is lost
- SvelteKit shows a 404 until the page is reloaded
- The URL stays on note 1 because the parameter is fixed
SvelteKit reuses the page component between URLs of the same route, so a value computed once never changes. $derived recalculates when page.params changes.
5. Two pages need the same list of notes. Where should that list live?
- In a variable inside the first of the two pages
- In the URL of every page that needs it
- In a
.svelte.jsmodule that both pages import - In a separate copy inside each of the two pages
A module is loaded once and shared by every page that imports it, and runes work in a .svelte.js file. Two copies of a list always end up different.
6. When should you use goto() instead of an <a href>?
- Whenever you want navigation with no page reload
- Whenever the target page is a dynamic route
- Whenever the link sits inside the navigation bar
- When navigation follows an action, such as a save
A link can be opened in a new tab, copied and announced as a link, so use it to go somewhere. <a href> already navigates with no reload. goto() is for code that decides to navigate, after a valid save for example.
7. A music app needs a page for every album, at addresses like /albums/81. New albums arrive every week. What do you create?
- A new folder with a
+page.svelteeach time an album arrives - One folder
src/routes/albums/[id]/with one+page.svelte - One
+layout.svelteinsrc/routesthat lists all the albums - One
albums.svelteinsrc/libwith an{#if}for each album
One plan for every house: a dynamic route answers every album with a single file, which reads page.params.id and must also decide what to show for an id that matches nothing.
Every note has an address now, but open NoteBox in two browsers and you get two different sets of notes. Next, the notes move to a server: Consuming data.