Menu

Tutorial

View as Markdown

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:

tsx
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 },
];
tsx
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:

tsx
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:

ts
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);
  }
});
ts
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.