Scaffold & the first page
In this chapter you set up the workspace and serve your first page: a typed route that returns a complete HTML document, with a typed param route and a real 404 alongside it. Over the next eight chapters you'll grow this into a small e-commerce app — catalog, cart, optimistic updates, streaming, and a behavior graph a machine can check.
Every code block in this tutorial is extracted at build time from a checked-in, compiling,
tested step state under site/tutorial/steps/ in the
kovo repository. One command — node site/tutorial/run-steps.mjs — typechecks every step, compiles every component through the real
compiler, and runs every step's tests, so a chapter and its code stay in sync. This chapter's
state is site/tutorial/steps/01-first-page/.
Prerequisites#
Kovo is pre-release, so you'll work inside the repository as workspace code. See
Installation for the prerequisites (Node 24+, pnpm 10+) and a tour of
what pnpm install sets up. You'll write strict TypeScript throughout — the framework's
correctness checks are checks on TypeScript programs.
Declare a catalog and a route#
Kovo is an MPA framework: each page is a complete document, there is no client router, and navigation is real navigation. A page starts on the server, with a route. You declare the route as a plain value, and the compiler captures its path string as a literal type:
export interface Product {
id: string;
name: string;
unitPrice: number;
}
export const catalog: Product[] = [
{ id: 'p1', name: 'Pour-over kettle', unitPrice: 1499 },
{ id: 'p2', name: 'Ceramic dripper', unitPrice: 2599 },
{ id: 'p3', name: 'Paper filters', unitPrice: 399 },
];export const homeRoute = route('/', {
page() {
return renderHomePage();
},
});route() hands you a value you can export, test, and point links at — it doesn't register
anything into a hidden router. Because the path is a literal type, every <Link>, GET form, and
redirect() that targets it is checked against it. Rename the path and every consumer turns red
under vp check. That pattern — declare once, derive everywhere, let renames be compiler errors
— recurs through the whole tutorial.
Add typed params and a real 404#
The product detail route declares its params schema once, coercion included — the same way form fields will declare theirs in chapter 4:
export const productRoute = route('/products/:id', {
params: s.object({ id: s.string() }),
page({ params }) {
const product = catalog.find((item) => item.id === params.id);
if (!product) return notFound();
return renderProductPage(product);
},
});notFound() is a page outcome, not an exception: return it and the route answers with a real
404 status, so status codes stay part of the typed surface. The render itself is ordinary string
assembly for now — components arrive in the next chapter:
export function renderHomePage(): string {
const items = catalog
.map(
(product) =>
`<li><a href="/products/${product.id}">${product.name}</a> — ${formatPrice(product.unitPrice)}</li>`,
)
.join('');
return `<!doctype html><html><head><title>Kovo Shop</title></head><body><main><h1>Kovo Shop</h1><ul>${items}</ul></main></body></html>`;
}Prove it without a browser#
Routes are values, so pages are request/response assertions. The step's test renders the route the same way a server would and checks the document:
it('serves the home page as a complete HTML document', async () => {
const response = await renderHomeRoute();
expect(response.status).toBe(200);
expect(response.headers['Content-Type']).toBe('text/html; charset=utf-8');
expect(response.body).toContain('<h1>Kovo Shop</h1>');
for (const product of catalog) {
expect(response.body).toContain(`href="/products/${product.id}"`);
expect(response.body).toContain(product.name);
}
});it('parses typed route params and renders the product page', async () => {
const response = await renderProductRoute('p2');
expect(response.status).toBe(200);
expect(response.body).toContain('<h1>Ceramic dripper</h1>');
expect(response.body).toContain('$25.99');
});
it('answers unknown products with notFound() and a real 404 status', async () => {
const response = await renderProductRoute('does-not-exist');
expect(response.status).toBe(404);
});This is the testing posture for the whole tutorial: the server renders complete, self-describing HTML, so you prove behavior from strings and status codes. No headless browser appears in any chapter.
Run this step's tests from the repo root with npx vitest --run site/tutorial/steps/01-first-page.
You now have typed routes serving complete documents, a real 404, and tests that need no browser. Next: the page's first interactivity — without shipping a framework to the client.
Spec & diagnostics
Tutorial goal and shape: SPEC §16, §1.1. Strict-TypeScript requirement: SPEC §6.6. MPA model and real navigation: SPEC §8. Typed route paths checked at every consumer: SPEC §6.4. Params schema with coercion: SPEC §6.3. Self-describing HTML proven from strings: SPEC §11.4. No browser in the tutorial: SPEC §16.3.