@k8ordo/static

routes/

The directory tree under src/routes/ is the application's URL space. This page covers the grammar of file and directory names, the order patterns are tried in, what the build refuses, the generated files, and how a page renders its title.

The directories are the pathname space

src/routes/ is the application's pathname space, and it holds nothing else. Adding a URL means adding a directory, and the file that answers a URL is found by reading the tree. The convention is not a habit to keep: it is a shape the build checks.

src/routes/
  layout.tsx
  page.tsx
  not-found.tsx
  error.tsx
  old/
    redirect.ts
  products/
    page.tsx
    [id]/
      page.tsx
  (docs)/
    layout.tsx
    guide/
      page.tsx
  _parts/
    counter.tsx
FileURLWhat it is
layout.tsxThe root layout around everything — the document itself
page.tsx/The root page
not-found.tsx/*Any pathname nothing else answered
error.tsxShown in place of what is below it when that throws
old/redirect.ts/oldSends the visitor to another URL
products/page.tsx/productsA literal segment
products/[id]/page.tsx/products/:idA parameter, handed to the page as params.id
(docs)/layout.tsxA layout wrapping only what is inside the group
(docs)/guide/page.tsx/guideA page inside the group; (docs) never appears in the URL
_parts/counter.tsxPrivate; invisible to the grammar

Five filenames

Inside a directory the grammar accepts five filenames — page.tsx, layout.tsx, not-found.tsx, error.tsx and redirect.ts — matched exactly, extension included, so page.ts fails the build as surely as helpers.ts does. Everything else goes under a directory whose name starts with _.

FileWhat it doesProps it receives
page.tsxRenders its directory's URL; the default export is the pageparams, pathname
layout.tsxWraps everything rendered below it, through childrenchildren, params, pathname
not-found.tsxAnswers any pathname below its directory that nothing else didparams, pathname
error.tsxA 'use client' file, shown in place of what is below it when that throwserror, reset
redirect.tsDefault-exports where to send the visitor, instead of a pageNone — nothing renders

In this mode a route file receives no request: a build that writes files has no request to read. not-found.tsx is rendered into one file, 404.html, so only one can be declared. Errors & redirects

Four kinds of directory name

A directory's name decides what it adds to the URL, if anything.

NameAdds to the URLMeaning
products/productsA name of letters, digits, ., _, ~ and - is one URL segment, exactly as written. Upper-case letters are allowed.
[id]/:idA parameter taking one segment. Its name starts with a letter or _, followed by letters, digits or _, and a name may appear only once along a path.
(docs)NothingA group, adding no segment. It exists to give part of the tree its own layout or error boundary. Its name uses letters, digits, _ and -.
_partsNothingA name starting with _ or . — directory or file — is invisible to the grammar. Components and data private to a route live here.

There is no variable-length parameter such as [...rest]. The grammar has one wildcard, and it is not-found.tsx; a directory named [...rest] is refused as an invalid param directory.

A page receives params; a layout receives children

Server Components cannot read context, so nesting travels by prop: a layout receives what renders below it as children, and a page receives its pattern's parameters as params. Both receive pathname, the URL this render is for — the only way a component above a parameter can see the value that parameter names.

// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';

export default function ProductPage({
  params,
  pathname,
}: PageProps<'/products/:id'>) {
  return (
    <>
      <h1>{params.id}</h1>
      <p>{pathname}</p>
    </>
  );
}

PageProps<pattern> and LayoutProps<pattern> from @k8ordo/router are those props by pattern. They read the generated Register, so nothing in the page depends on which mode is installed. Declaring the props inline works as well: either way the generated table checks them where it uses the component (satisfies), and tsc is what reports it.

This site's root layout decides <html lang> from the first segment of pathname: the locale lives only in the URL, and crawlers and screen readers read the HTML the server wrote.

// src/routes/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../i18n';

export default function Root({
  children,
  pathname,
}: {
  children: ReactNode;
  pathname: string;
}) {
  const locale = locales.delocalize(pathname).locale ?? locales.default;
  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

The order patterns are tried in

A directory tree has no order of its own, so the generated table chooses one: at each level a literal segment is tried before a parameter, and not-found.tsx comes last in its branch. That is why about/ beside [slug]/ reaches /about without anything being said.

A group is the one exception. It holds URLs of both kinds under one key, and the table cannot interleave across it. In the tree below, the literal sale/ inside the group ranks it level with about/, the group's name sorts first, and its [id]/ then answers /about before about/ is tried. This is the one shape where a declared route can never render, so the build reports it rather than shipping it.

src/routes/
  page.tsx
  about/
    page.tsx
  (shop)/
    sale/
      page.tsx
    [id]/
      page.tsx
routes/ is not a valid pathname space:
  routes/about/page.tsx: "/about" can never match "/:id" ((shop)/[id]/page.tsx) is declared first and answers it

What the build refuses

Every problem is reported, not just the first, and each names its file. The build stops with routes/ is not a valid pathname space: followed by one line per problem, and vite dev refuses to start with the same error when the problem is there at startup. A problem introduced while vite dev is running is logged as routes/<path>: <message>, and the server keeps running. While there are problems, .k8ordo/ is not rewritten.

routes/ is not a valid pathname space:
  routes/[123]: "[123]" is not a valid param directory use [name] with a letter or underscore first
  routes/products/helper.ts: routes/ holds only page.tsx, layout.tsx, not-found.tsx, error.tsx, redirect.ts move "helper.ts" under a _-prefixed directory
routes/ containsError
products/helper.tsroutes/ holds only page.tsx, layout.tsx, not-found.tsx, error.tsx, redirect.ts — move "helper.ts" under a _-prefixed directory
[123]/page.tsx"[123]" is not a valid param directory — use [name] with a letter or underscore first
(docs/page.tsx"(docs" is not a valid route group — use (name)
pro ducts/page.tsx"pro ducts" cannot be a URL segment — use letters, digits, . _ ~ or -
[id]/things/[id]/page.tsx":id" is already taken by an ancestor — params must be unique within a path
orphan/layout.tsx and no page below ithas a layout but no page.tsx below it, so it can never render
empty/error.tsx and no page or redirect below itdeclares no route — every directory needs a page.tsx (or redirect.ts) somewhere below it
(a)/page.tsx and (b)/page.tsx"/" is already declared by (a)/page.tsx — route groups do not separate URLs
old/page.tsx and old/redirect.ts"old" cannot both render page.tsx and redirect — keep one
(shop)/sale/page.tsx and (shop)/[id]/page.tsx beside about/page.tsx"/about" can never match — "/:id" ((shop)/[id]/page.tsx) is declared first and answers it
(shell)/docs/page.tsx and (shell)/not-found.tsx beside about/page.tsx"/about" can never match — "/*" ((shell)/not-found.tsx) is declared first and answers it

The shadowing check runs only on a tree with no grammar problems, because a name the grammar refused is not a pattern. Fixing the grammar problems can therefore surface a shadowed route on the next run.

A build in this mode also stops for these reasons:

  • A parameterised route with no pathnames, or a value returned by paths that cannot be used Parameters
  • More than one not-found.tsx Build & deploy
  • A module that declares 'use server' Get Started
  • A component that throws while the build renders it — a Server Component always, a client component when no Suspense boundary sits above it Errors & redirects

The generated files

The framework writes .k8ordo/ and keeps it in step with the directories: when vite dev starts and when vite build begins, and again during development whenever a file under routes/ is added, removed or changed. It is generated and not yours to edit — but it is ordinary source using only the router's public API, so it is yours to read.

FileWhat it holds
routes.gen.tsThe table itself (routes), the schemas that run per page pattern (paramSchemas), and the redirects (redirects). Each route file is checked with satisfies against the pattern its directory puts it under.
register.gen.tsWires the table into @k8ordo/router's Register, and into @k8ordo/state's when the application depends on it — which is why the patterns href and useMatch take are checked against the table.
.gitignoreContains *: the directory ignores itself, so there is nothing to add to the application's own .gitignore.

This tree produces the two files below, both excerpted: routes.gen.ts down to its routes export, and register.gen.ts to its declaration for an application that does not depend on @k8ordo/state. The real files begin with a // Generated by … line naming the mode package that wrote them.

src/routes/
  layout.tsx
  page.tsx
  not-found.tsx
  products/
    page.tsx
    [id]/
      page.tsx
// .k8ordo/routes.gen.ts
export const routes = defineRoutes({
  '/': {
    layout: layout satisfies Layout<'/'>,
    children: {
      '/': page satisfies Page<'/'>,
      '/products': {
        children: {
          '/': products_page satisfies Page<'/products'>,
          '/:id': products_id_page satisfies Page<'/products/:id'>,
        },
      },
      '/*': not_found satisfies Page<'/*'>,
    },
  },
});
// .k8ordo/register.gen.ts
import type { ParsedParamsMap } from '@k8ordo/router';
import type { paramSchemas, routes } from './routes.gen';

declare module '@k8ordo/router' {
  interface Register {
    routes: typeof routes;
    params: ParsedParamsMap<typeof paramSchemas>;
  }
}

tsconfig.json

For the type wiring to apply, list .k8ordo in tsconfig.json's include with a glob. The name starts with a dot, and a bare directory entry silently skips it — the build still works, and href simply stops being checked against the table.

{
  "include": ["src/**/*.ts", "src/**/*.tsx", ".k8ordo/**/*.ts"]
}

vite build does not type-check; the satisfies checks report through tsc. .k8ordo/ is not in git, so on a fresh checkout run vite dev or vite build once before tsc to write it.

Titles and metadata

There is no metadata API: React 19 hoists a <title>, <meta> or <link> rendered anywhere in the tree into <head>, so a page renders its own title where it renders everything else.

// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';

export default function ProductPage({ params }: PageProps<'/products/:id'>) {
  return (
    <>
      <title>{`Product ${params.id}`}</title>
      <meta content="One product from the catalog" name="description" />
      <h1>{params.id}</h1>
    </>
  );
}

Keep one <title> on screen at a time: the root layout renders none, and each page and not-found.tsx renders its own. Two titles are two titles, not a fallback chain — React renders both. This site renders each page's through a PageTitle component.

Links are checked against the table

Navigation in the browser is @k8ordo/router's. Under the Navigation API a plain <a> is already a client navigation, and the pattern and params given to href() are checked against the generated table.

// src/routes/products/page.tsx
import { href } from '@k8ordo/router';

export default function ProductsPage() {
  return (
    <ul>
      <li>
        <a href={href('/products/:id', { id: 1 })}>first product</a>
      </li>
      <li>
        <a href={href('/guide')}>guide</a>
      </li>
    </ul>
  );
}

How the router behaves under the framework is covered here. Links & location · Under the framework