In this part you create the app and give it three pages: a home page, a list of tasks, and a page for each task. By the end you can click from one to the other, a task that does not exist shows a “Not found” page, and the build turns every page into a file it can serve without rendering.
Create the app
bun create rsc-kit@latest tasks --yes--yes takes the default answer to each of its questions: where the app runs,
Tailwind for styling, Zod for validation, and a few more. Leave it out to choose
for yourself; nothing in this tutorial depends on the answers except Tailwind,
and only for how the pages look. Then:
cd tasks
bun run devOpen localhost:3000. You see a heading and a button that counts clicks.
Leave the dev server running for the whole tutorial. When you save a file, the browser updates by itself.
Look at what you got
Two files make that page:
src/app/layout.tsxis the layout. It writes the<html>and<body>around every page.src/app/page.tsxis the page at/.
Both are server components. They run on the server, and only their HTML
reaches the browser, not their code. The click counter is different:
src/components/Counter.tsx starts with 'use client', which makes it a
client component. Its code is sent to the browser so the button can
respond to clicks.
That is the rule for the whole app: a component is a server component unless
its file says 'use client'. You will write your first client component in a
later part. For now, every page is a server component.
Clear the starter
Delete the counter:
rm src/components/Counter.tsxand replace the home page:
import Link from '@rsc-kit/core/Link'
export default function HomePage() {
return (
<>
<h1 className="text-3xl font-bold">Tasks</h1>
<p className="mt-4 text-slate-600">A small task board for a team.</p>
<Link href="/tasks" className="mt-6 inline-block underline">
See the tasks
</Link>
</>
)
}Link is an <a> that loads the next page without reloading the whole
document. The class names are Tailwind, which the
starter set up. You can leave them out; nothing in this tutorial depends on
how it looks.
The browser shows the new page. The link goes nowhere yet.
Add some tasks
The app needs data to show. A database comes in a later part. For now, a list in a file:
export type Task = {
id: string
title: string
done: boolean
}
export const tasks: Task[] = [
{ id: '1', title: 'Write the README', done: true },
{ id: '2', title: 'Set up the database', done: false },
{ id: '3', title: 'Invite the team', done: false },
]This file is not a page. Only a file named page.tsx makes a url, so you can
keep anything else anywhere in src.
A page that lists them
A url is a folder with a page.tsx in it. src/app/tasks/page.tsx is the page
at /tasks:
import Link from '@rsc-kit/core/Link'
import type { Metadata } from '@rsc-kit/core/metadata'
import { tasks } from '../../tasks'
export const metadata: Metadata = { title: 'All tasks' }
export default function TasksPage() {
return (
<>
<h1 className="text-2xl font-bold">All tasks</h1>
<ul className="mt-6 space-y-2">
{tasks.map((task) => (
<li key={task.id}>
<Link href={`/tasks/${task.id}`} className={task.done ? 'text-slate-400 line-through' : ''}>
{task.title}
</Link>
</li>
))}
</ul>
</>
)
}Click See the tasks. You see the three tasks, and the first is crossed
out because it is done. The browser tab says “All tasks · tasks”: metadata
sets the page’s <title>, and the layout adds the app’s name after it.
Notice there is no fetching here. A server component reads its data directly, the way any server code does, because it runs on the server.
A menu on every page
To get back home you need the browser’s back button. A menu belongs on every page, and what is on every page belongs in the layout. Replace it:
import './styles.css'
import type { ReactNode } from 'react'
import Link from '@rsc-kit/core/Link'
import type { Metadata } from '@rsc-kit/core/metadata'
export const metadata: Metadata = {
title: { template: '%s · Tasks', default: 'Tasks' },
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-white text-slate-900">
<nav className="flex gap-4 border-b px-8 py-4">
<Link href="/" className="font-semibold">Home</Link>
<Link href="/tasks">Tasks</Link>
</nav>
<main className="mx-auto max-w-2xl p-8">{children}</main>
</body>
</html>
)
}children is the page. Click between Home and Tasks: only what is
inside <main> changes. The layout stays on screen, because both pages share
it.
A page for each task
The links go to /tasks/1, /tasks/2 and /tasks/3. You do not make three
folders. A folder whose name is in square brackets matches any value, and the
page receives that value as a param:
import Link from '@rsc-kit/core/Link'
import { tasks } from '../../../tasks'
export default async function TaskPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const task = tasks.find((t) => t.id === id)
return (
<>
<Link href="/tasks" className="text-sm text-slate-500">← All tasks</Link>
<h1 className="mt-4 text-2xl font-bold">{task?.title}</h1>
<p className="mt-2">{task?.done ? 'Done' : 'Not done yet'}</p>
</>
)
}params is a promise, so the page is async and awaits it. The guide on
routing explains why it is a promise; you
do not need that yet.
Click a task. You see its title and whether it is done.
A task that does not exist
Open localhost:3000/tasks/99. The page shows an empty heading and “Not done yet”, and the status is 200, as if the page were fine. A missing task should be a 404.
Call notFound() when there is no task. Change the page; the new lines are
the notFound import and the if:
import Link from '@rsc-kit/core/Link'
import { notFound } from '@rsc-kit/core/not-found'
import { tasks } from '../../../tasks'
export default async function TaskPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const task = tasks.find((t) => t.id === id)
if (!task) notFound()
return (
<>
<Link href="/tasks" className="text-sm text-slate-500">← All tasks</Link>
<h1 className="mt-4 text-2xl font-bold">{task.title}</h1>
<p className="mt-2">{task.done ? 'Done' : 'Not done yet'}</p>
</>
)
}notFound() stops the page, so after it task is always there and the ?s
can go.
/tasks/99 now says “Not found”, but in plain text with no menu. Give the app
its own page for that:
import Link from '@rsc-kit/core/Link'
export default function NotFound() {
return (
<>
<h1 className="text-2xl font-bold">Not found</h1>
<Link href="/tasks" className="mt-4 inline-block underline">Back to the tasks</Link>
</>
)
}Reload /tasks/99. The menu is there, because not-found.tsx is shown inside
the layout. Any url the app has no page for, like /nope, shows it too.
Build it
The dev server renders each page when you ask for it. A build prepares the app for production, and part of that is rendering every page once, ahead of time, so it can be sent as a file instead of rendered for each visitor. Stop the dev server with Ctrl+C and run:
bun run buildThe build fails:
○ / 92 kB
○ /tasks 92 kB
✗ /tasks/_id_
blocks before anything can paint. Add a loading.tsx beside it, or put a <Suspense> above the waiting, and it has a skeleton to store.
- /sitemap.xml
not written: the root layout has no metadataBase to make the urls absolute
○ (Static) prerendered as static content
✗ (Failed) did not render, or painted nothing — the build stops here
2 static, 1 failed
error during build:
Error: Some routes could not be prerendered./ and /tasks are fine. They are marked ○, which means static: the
build rendered them and stored the result. /tasks/[id] failed, because the
build does not know which ids exist, so it had nothing to render.
Ignore the /sitemap.xml line. A sitemap needs the site’s address, which the
app does not have yet, so the build skips it.
The build stops rather than guess. Otherwise a page that has to be rendered on every visit would ship without anyone deciding it should. You can fix it in two ways:
- Tell the build which ids exist, and it renders each one. That fits here: the tasks are in a file, so the list is known.
- Give the page something to show while it waits, with a
loading.tsx. That fits when the ids are only known at request time, like rows in a database. You will do that in a later part.
Tell the build. Add generateStaticParams to the task page, above the
component:
import Link from '@rsc-kit/core/Link'
import { notFound } from '@rsc-kit/core/not-found'
import { tasks } from '../../../tasks'
export function generateStaticParams() {
return tasks.map((task) => ({ id: task.id }))
}
export default async function TaskPage({ params }: { params: Promise<{ id: string }> }) {
// unchanged
}It returns one object per url, each holding the params for that url. Build again:
bun run build ○ / 92 kB
○ /tasks 92 kB
○ /tasks/1 92 kB
○ /tasks/2 92 kB
○ /tasks/3 92 kB
- /sitemap.xml
○ (Static) prerendered as static content
5 staticEvery page is static. Run the built app:
bun run startand open localhost:3000. It is the same app, now sent
from the files the build stored. /tasks/99 is still a 404.
What you built
src/
├── tasks.ts the data
└── app/
├── layout.tsx the menu, around every page
├── not-found.tsx any url with nothing to show
├── page.tsx /
└── tasks/
├── page.tsx /tasks
└── [id]/
└── page.tsx /tasks/1, /tasks/2, …You learned:
- A url is a folder in
src/appwith apage.tsxin it. - A folder named
[id]matches any value and passes it to the page inparams. - A layout wraps every page below it and stays on screen when you move between them.
- Components are server components unless the file says
'use client'. A server component reads data directly. notFound()makes a 404, andnot-found.tsxis what it shows.- The build renders every page it can and stops on one it cannot, telling you how to fix it.
To read more about any of this, see Routing, Errors and Static generation.