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 route page and 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 22.15+, 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 starts as real navigation. When JavaScript is present, Kovo may enhance an eligible same-origin click by fetching the full target document and preserving only compiler-proven unchanged layouts. If that proof is missing, the browser performs the normal 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('/', {
access: publicAccess('tutorial storefront browsing'),
page() {
return (
<html>
<head>
<title>Kovo Shop</title>
</head>
<body>
<main>
<h1>Kovo Shop</h1>
<ul>
{catalog.map((product) => (
<li key={product.id}>
<a href={`/products/${product.id}`}>{product.name}</a> —{' '}
{formatPrice(product.unitPrice)}
</li>
))}
</ul>
</main>
</body>
</html>
);
},
});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 kovo check. That pattern — declare once, derive everywhere, let renames be compiler errors
— recurs through the whole tutorial.
The publicAccess(...) line is equally deliberate: every request surface records an access
decision. This storefront is public by design; there is no implicit allow.
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', {
access: publicAccess('tutorial storefront browsing'),
params: s.object({ id: s.string() }),
page({ params }) {
const product = catalog.find((item) => item.id === params.id);
if (!product) return notFound();
return (
<html>
<head>
<title>{product.name} · Kovo Shop</title>
</head>
<body>
<main>
<h1>{product.name}</h1>
<p>{formatPrice(product.unitPrice)}</p>
<a href="/">Back to the shop</a>
</main>
</body>
</html>
);
},
});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. Both route pages return ordinary TSX
directly at the route boundary, which lets the compiler enroll and lower the complete page;
stateful components arrive in the next chapter.
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: rules/v1-acceptance.md and SPEC §1.2. 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: rules/v1-acceptance.md. Explicit default-deny access decisions: SPEC §10.2, KV436.