@k8ordo/framework

Routing

The directories under src/routes/ are the application’s URLs. Once you know the rules for directories and files, a page, a redirect or a feed is one file in the right place.

On this page

Directories and URLs

text
src/routes/
  layout.tsx
  page.tsx
  not-found.tsx
  products/
    page.tsx
    [id]/
      page.tsx
  (docs)/
    layout.tsx
    guide/
      page.tsx
  • page.tsx: the page at /.
  • products/page.tsx: the page at /products.
  • products/[id]/page.tsx: the page at /products/:id. Open /products/42, and the page receives 42 as params.id.
  • (docs)/guide/page.tsx: the page at /guide.

Directory name shapes

The shape of a directory name decides what it adds to the URL.

  • products: adds one segment, exactly as written. Letters, digits, ., _, ~ and - are allowed.
  • [id]: a parameter that takes one segment. Its name starts with a letter or _.
  • (docs): a group, which adds no segment. Use it to give part of routes/ a layout or an error.tsx of its own.
  • A name starting with .: never read, directory or file. A .DS_Store or an editor’s temporary file does not break the build.

There is no parameter that takes several segments, such as [...rest].

Route files

A directory may hold only files with these nine names, extension included. Even a page.ts, with only the extension wrong, fails the build.

  • page.tsx: the page shown at the directory’s URL.
  • layout.tsx: wraps whatever renders below it, received as children.
  • not-found.tsx: shown for any URL below it that no route matched.
  • error.tsx: rendered in place of what is below it when that throws.
  • loading.tsx: rendered while the page below it loads.
  • redirect.ts: default-exports a destination, instead of a page.
  • route.ts: returns a Response instead of a page.
  • guard.ts: runs before any request below it is handled, and stops it or lets it through. Server mode only.
  • fallback.tsx: answers a URL whose value paths did not list, in place of the page.tsx beside it. Static mode only.

src/routes/ holds route files and directories, nothing else. Components go in src/components/, and every other module — Server Actions, schemas, state definitions, data, helpers — in src/lib/.

Page and layout props

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

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

A page receives params, and a layout receives children. Both receive pathname, the URL being rendered. A layout above a parameter reads the value from pathname.

PageProps and LayoutProps from @k8ordo/framework are these props looked up by pattern. They read the generated route table, so the pattern string is the only type argument. LayoutProps takes only a pattern that has a page: without products/page.tsx, the layout for /products writes its props type itself.

Under server mode a page and a layout also receive request. Reading its headers and cookies is covered in Request and cookies.

Router API

src/components/products-link.tsx
'use client';

import { href, useMatch } from '@k8ordo/framework';

export function ProductsLink() {
  const showing = useMatch('/products/*', { inclusive: true }) !== null;
  return (
    <a aria-current={showing ? 'page' : undefined} href={href('/products')}>
      Products
    </a>
  );
}

The APIs for links and the current URL, such as href and useMatch, are imported from @k8ordo/framework, which re-exports them from @k8ordo/router by name. The hooks work only in Client Components.

useParams and useRoute are not in @k8ordo/framework: the browser holds no route table, so they would have no route to read. What to use instead is covered in Server and browser.

The locale segment

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

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

export const { paramsSchema } = locales;

export default function LocaleLayout({
  children,
}: {
  children: ReactNode;
}) {
  return children;
}

From the [locale] layout, export the paramsSchema of the locales you defined with defineLocales. The schema accepts only the locales in the set. Where the accepted locale is read is covered in Put the locale in the URL.

The framework validates the parameter with this schema as it does with any paramsSchema, and renders the pages below in the accepted locale. / has no locale, so the application itself picks a language at / and redirects to a URL that has one. Picking it is covered in Choose the first language.

Under static mode a page is written per locale, so paths takes locales.paths. Passing it is covered in Parameters.

Route order

text
src/routes/
  about/
    page.tsx
  (shop)/
    sale/
      page.tsx
    [id]/
      page.tsx

Directories have no order of their own, so the generated route table picks one. At each level a fixed segment is tried before a parameter, and not-found.tsx comes last. Put [id]/ directly beside about/, and /about still reaches about/.

A group, though, keeps its routes together in one place. The table does not reorder across groups. In the example above, (shop) holds the fixed segment sale/, so it ranks level with about/. At the same rank routes sort by name, which puts (shop) first.

So (shop)/[id]/ answers /about first, and about/page.tsx never renders. This shape fails the build with an error that begins "/about" can never match.

Invalid structure

When a file or directory breaks the rules, the build fails. The error begins routes/ is not a valid pathname space: and lists one line per problem. It lists every problem it found, not just the first, and each line names the file.

  • A file that is not a route file (products/helper.ts)
  • A malformed directory name ([123], (docs, pro ducts)
  • A parameter named the same as one above it ([id]/things/[id]/)
  • A directory with no page, redirect.ts or route.ts anywhere below it (only a layout or an error.tsx, say)
  • Pages at the same URL in different groups ((a)/page.tsx and (b)/page.tsx)
  • Any two of page.tsx, redirect.ts and route.ts in one directory
  • A route.ts that exports no method
  • A page that never shows because a route inside a group answers first
  • A fallback.tsx with no page.tsx beside it
  • A fallback.tsx in the same directory as a layout.tsx
  • A fallback.tsx for a page with no parameter
  • A fallback.tsx for a page whose every parameter a layout above receives

vite dev refuses to start with the same error when a problem exists at startup. A problem introduced while it runs is logged as routes/<path>: <message>, and the server keeps going. While a problem remains, .k8ordo/ is not rewritten.

Under static mode the build also fails on what needs a server. Which ones, and why, is covered in Choosing a mode.

Loading state

src/routes/products/loading.tsx
export default function ProductsLoading() {
  return <p>Loading products…</p>;
}

A loading.tsx beside a layout.tsx or a page.tsx is rendered while the page below it loads. It receives no props.

Under static mode a page is written whole into a file, so its HTML never shows a loading.tsx. A client navigation rarely shows one either: the payload is a file too, and usually arrives in one piece.

Under server mode a document waits for the page’s own component before it is sent, so its HTML does not show it, and a visitor without JavaScript, or a crawler, sees the page. The exception is a component inside the page that awaits data outside a <Suspense> of its own: the HTML then shows it in the page’s place. During a client navigation it shows from when the payload starts arriving until the server has finished rendering the page.

src/components/progress.tsx
'use client';

import { usePendingPathname } from '@k8ordo/framework';

export function Progress() {
  const pending = usePendingPathname();
  if (pending === null) return null;
  return <p role="status">Loading {pending}…</p>;
}

Moving to another page within a directory already on screen keeps the current page showing until the next one arrives. To show something meanwhile, read where the navigation is going with usePendingPathname().

route.ts

src/routes/feed.xml/route.ts
import type { RouteContext } from '@k8ordo/framework';

import { renderFeed } from '../../lib/feed.server';

export async function GET({ request }: RouteContext<'/feed.xml'>) {
  const { origin } = new URL(request.url);
  return new Response(await renderFeed(origin), {
    headers: { 'content-type': 'application/rss+xml' },
  });
}

A URL that returns something other than a page, such as an RSS feed or JSON, gets a route.ts. It exports a function named after each HTTP method it handles, and that function returns a Response. The function receives { request, params }, typed RouteContext. Its params are typed by the schemas, as a page’s are.

A directory named like a file, such as feed.xml, is an ordinary segment, so feed.xml/route.ts is the URL /feed.xml. The Response a route.ts returns gets none of the layouts above it. A client navigation to its URL reloads the whole page.

Static mode

The build calls GET once per URL and writes the Response it returns as the file at that URL: feed.xml/route.ts becomes dist/client/feed.xml. A route.ts with parameters takes its values from paths, as a page does. With site given to framework(), the origin of request.url is that value.

Warning

Pitfall

These route.ts files cannot become files, so they fail the build:

  • A route.ts exporting a method other than GET (vite dev refuses it too)
  • A route.ts whose GET returns anything but 200
  • A route.ts at /
  • A route.ts with pages below it

Server mode

Any of the seven HTTP methods may be exported. A method it does not export gets a 405, with the exported ones listed in Allow. Without a HEAD export, the GET response is returned without its body.

The guard.ts files above it run first. cookies(), responseHeaders() and requestHeaders() from @k8ordo/framework/server work inside a route.ts too.

Warning

Pitfall

A POST to a route.ts is not checked for the same origin, unlike a Server Action: what posts there, such as a webhook, comes from elsewhere. Write the checks you need inside the route.ts.

The generated .k8ordo/

.k8ordo/routes.gen.ts
import { defineRoutes } from '@k8ordo/framework/generated';
// …

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'>,
        },
      },
    },
  },
});

The framework writes a route table and its types, made from routes/, into .k8ordo/. It writes when vite dev starts and when vite build begins, and during development whenever a file under routes/ changes.

  • routes.gen.ts: the route table itself. It checks each route file against its directory’s pattern with satisfies.
  • register.gen.ts: registers the table with the framework, which types the patterns href() and useMatch() take. Under server mode it also adds request to PageProps and LayoutProps. When the application depends on @k8ordo/state, it registers the table there too.
  • .gitignore: ignores everything in it with *. That keeps .k8ordo/ out of git, so nothing goes into the application’s own .gitignore.

It is generated, so do not edit it. It is plain TypeScript importing from @k8ordo/framework/generated, and reading it shows what is checked.

vite build does not type-check, so run tsc to see the satisfies checks. In a fresh clone, run vite dev or vite build once before tsc.

Titles and metadata

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

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

There is no metadata API. React 19 moves a <title>, <meta> or <link> rendered in any component into <head>. A page renders its title where it renders everything else.

Keep one <title> on screen at a time. The root layout renders none, and each page and not-found.tsx renders its own. Render two and both are output; the later one does not replace the earlier.

Prefetching links

tsx
<nav data-k8ordo-prefetch="false">
  <a href={href('/reports')}>Reports</a>
  <a data-k8ordo-prefetch="true" href={href('/')}>
    Home
  </a>
</nav>

When a pointer moves onto a link, the client runtime fetches the payload of the page it points to. Focusing the link or starting a press on it does the same. There is nothing to set up: every <a> on the page counts, including the ones a component library renders. These links are not prefetched:

  • one to another origin, or outside Vite’s base
  • one with download, or with a target other than _self
  • one to the page on screen

To stop prefetching, mark the link or any element around it data-k8ordo-prefetch="false". Use it for links to pages that are expensive to render. The nearest element carrying the attribute decides, so "true" brings one link back inside a region that opted out.

A prefetched page is used once, by the next navigation to it, and only within 30 seconds of the fetch starting. After that the navigation fetches it again. A Server Action’s result drops everything prefetched too.

Information

Note

The browser’s Speculation Rules are not used, since they are not Baseline.

Playground

Prefetch demo

This site runs on @k8ordo/framework in static mode, so pointing at a link really fetches its index.rsc and shows it in the list below.

The parameters page
Links in here are not prefetchedThe deploy page

Payloads fetched

None yet

Try it

  1. Point at “The parameters page”. Its index.rsc joins the list.
  2. “The deploy page” sits inside data-k8ordo-prefetch="false", so pointing at it adds nothing.
  3. Point at “The parameters page” again. Nothing is added until 30 seconds after the first fetch.
  4. Point at a link to another page in the header or the sidebar. It joins the list the same way.
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2