Ajo Kit
Routing
Pages, layouts, dynamic segments, groups, navigation and pending states.
Kit builds its routes from the files under src. A page.tsx is the component for a URL, a layout.tsx wraps every page below its directory, and the directory names make the path. The same route table serves the first request on the server and every navigation after it in the browser.
From files to URLs
Kit collects every file that matches src/**/{layout,page}.{js,ts,jsx,tsx}. The directory that holds a page file becomes its path.
| File | URL | On the server |
|---|---|---|
src/page.tsx | / | |
src/notes/page.tsx | /notes | |
src/notes/[id]/page.tsx | /notes/:id | req.params.id |
src/help/[...]/page.tsx | /help/* | req.params['*'] |
src/(account)/login/page.tsx | /login |
Any file named page or layout is a route file, whatever directory it sits in, so give other components different names. Components, helpers and data modules can live anywhere under src.
Dynamic segments and groups
A directory named in brackets, such as [id], matches one path segment and exposes it as a parameter. Loaders and actions read it from req.params, already URL-decoded.
A catch-all is written exactly [...], with no name. It matches everything after its parent path: src/help/[...]/page.tsx matches /help/install and /help/kit/cli, and the remainder is req.params['*']. It does not match /help itself; give that URL its own page.
A directory named in parentheses, such as (account), is a group. It adds nothing to the URL, but it can hold a layout.tsx, a handler.ts or a wares.ts. Groups give several routes a shared layout or shared middleware without a shared path prefix.
Nested layouts
Every directory on the way to a page can contribute a layout. For /notes/42, Kit composes src/layout.tsx, then src/notes/layout.tsx, then src/notes/[id]/page.tsx, passing each level to the one above as children. A directory without a layout.tsx adds no level.
import type { LayoutArgs } from 'ajo-kit'
type Data = { notes: { id: number; title: string }[] }
export default ({ data, children }: LayoutArgs<Data>) => (
<div class="notebook">
<nav aria-label="Notes">
<ul>
{data?.notes.map(note => (
<li key={note.id}><a href={'/notes/' + note.id}>{note.title}</a></li>
))}
</ul>
</nav>
<section>{children}</section>
</div>
)Each component receives its own slice of server data: a layout gets what its directory’s layout() loader returned, and the page gets what page() returned. Loaders explains how those are produced and how a page can read its layouts’ data.
Page and layout arguments
Pages receive PageArgs and layouts receive LayoutArgs, both exported as types from ajo-kit and generic over the shape of data.
| Argument | Type | Meaning |
|---|---|---|
params | Record<string, string> | Route parameters from the client router. See the note in Dynamic segments. |
data | T | undefined | This level’s loader result; {} when it has no loader. undefined while a navigation is pending and after an error. |
loading | boolean | True only for the pending boundary, while a navigation waits for data. |
error | Failure | undefined | Set when the route failed. See Errors. |
children | Children | Layouts only: the next layout, or the page. |
Client navigation
After the first load, Kit’s client router handles same-origin links. A click pushes a history entry, fetches the new route’s data as JSON and renders it into the page already on screen. The browser keeps the click when the link:
- has a
targetattribute, such astarget="_blank"ortarget="_self"; - points to another host, or its
hrefstarts with#; - is clicked with a modifier key or a button other than the primary one;
- was already handled by a listener that called
preventDefault().
Every other same-origin link becomes a client navigation, including links to static files or /api endpoints. Give those target="_self" so the browser loads them itself.
To navigate from code, call navigate(). It pushes a history entry and runs the router; it has no replace option.
import { navigate } from 'ajo-kit'
navigate('/notes/42')The router also runs on back and forward, and whenever code calls history.pushState() or history.replaceState(), so changing the URL through the History API loads that route again, loaders included. The query string is part of the request: /notes?tag=work runs the /notes loaders with req.query.tag set. Route patterns match the path only.
Scroll, fragments and focus
When a navigation finishes, Kit scrolls to the element whose id matches the URL’s fragment, or to the top when there is no fragment or no such element. The scroll is smooth unless the user prefers reduced motion. It happens after every navigation, back and forward included; earlier scroll positions are not restored. A link to another route with a fragment, such as /docs/kit/loaders#head, loads the route and then scrolls to the heading. A fragment-only link stays with the browser and loads nothing.
Kit does not move keyboard focus. If you want screen reader and keyboard users to land on the new content, a layout can focus the page’s main heading once a navigation settles.
Pending navigation
Between a click and the arrival of the new route’s data, Kit renders the new route once without data. The pending export chooses which single component receives loading during that render:
export const pending = true- If the page module exports it, the page receives
loading. - Otherwise the innermost layout that exports it does.
- If none does, every component renders with
loadingfalse.
pending only decides who draws the loading state. It does not change when loaders run or when data is fetched. During that render data is undefined for every layout and for the page, so each of them must tolerate it.
Keep the previous page while loading
A root layout can hold on to the page it last showed and keep rendering it until the new route is ready. The demo application in the ajo-kit repository uses this pattern:
import type { Children, Stateful } from 'ajo'
import type { LayoutArgs } from 'ajo-kit'
import Problem from './problem'
export const pending = true
const Root: Stateful<LayoutArgs> = function* (args) {
let previous: Children = args.children
for (args of this) {
if (args.loading) yield (
<>
<p class="loading" role="status">Loading…</p>
<main key="main" aria-busy="true">{previous}</main>
</>
)
else if (args.error) yield <main key="main"><Problem error={args.error} /></main>
else {
previous = args.children
yield <main key="main">{args.children}</main>
}
}
}
export default RootWhile loading is true the layout renders its previous children again, which are the previous route’s layouts and page with their previous data, and adds an indicator. When the route settles it swaps in the new children. This works as long as no deeper layout or page also exports pending, because only the innermost boundary receives loading. Delaying the indicator with a CSS animation keeps fast navigations from flashing it.
Errors and missing pages
When a route fails, components receive error in place of data. It carries a status and a message.
| Cause | Status |
|---|---|
A loader, head() or middleware throws Missing, Forbidden, Denied, Invalid or Failure | 404, 403, 401, 400, or the status given to Failure |
| Any other thrown error | Its status or statusCode when that is between 400 and 599; otherwise 500 |
| No page matches the URL | 404 |
| The navigation request fails in the browser | 500 |
On a document request the server renders the root layout with error, leaves the page empty, and responds with the error’s status. On a client navigation the matched route’s layouts and page all receive error; for a URL that matches nothing, only the root layout does. The root layout sees the error in every case, so give the application a src/layout.tsx that handles it, as in the layout above, and don’t render children while it is set.
import type { Stateless } from 'ajo'
type Args = { error: { status: number; message: string } }
const Problem: Stateless<Args> = ({ error }) => (
<section class="problem">
<h1>{error.status === 404 ? 'Page not found' : 'Something went wrong'}</h1>
{error.status < 500 && <p>{error.message}</p>}
<p><a href="/">Back to your notes</a></p>
</section>
)
export default ProblemAfter a server-rendered error, the client continues with error as a plain object holding status and message, not a Failure instance. Compare error.status rather than using instanceof.
What persists between routes
Kit does not reload the document between routes. Each navigation builds a new component tree, and Ajo reconciles it with the DOM already on screen:
- Layouts are keyed by their directory. A layout that wraps both the old and the new route keeps its element, and a stateful layout also keeps its generator, local variables and listeners, receiving the new arguments in its render loop.
- A stateful page keeps its instance when the next URL resolves to the same page under the same layouts, as from
/notes/1to/notes/2. It gets new arguments, not a fresh start. Reset per-note state yourself, or give a stateful child a key such askey={data.note.id}so it starts over for each note. - Stateless pages and layouts render in place. Elements that line up by position and tag are updated rather than recreated.
That is why a sidebar’s scroll position, an open menu or a playing animation in a shared layout survives a navigation. Live updates and refreshes after actions take the same path: they render the active route again with new data, without navigating.
- LoadersProduce the data each layout and page receives.
- ActionsHandle forms and writes on the server.
- API routes and middlewareGuard a branch of routes with wares.ts.
Source of truth: ajo-kit README