Ajo Kit
Loaders
Read server data with page() and layout(), combine it with parent() and set head().
A handler.ts next to a page or layout holds its server code. Kit keeps these modules out of the browser bundle, so a loader can query the database, read secrets and check permissions. Whatever it returns becomes the data argument of the matching component, on the first request and on every navigation after it.
Loader exports
| Export | Runs for | Returns |
|---|---|---|
layout(req, parent) | Every page in or below this directory, when the directory has a layout.tsx | The layout’s data |
page(req, parent) | The page in this directory | The page’s data |
head(req, parent) | Its own level, after all loaders have finished | A Head object |
Each may be synchronous or async. The same file can also export actions and API methods. Take a notebook application where each note lives in a notebook:
src/
layout.tsx
handler.ts layout() and head() for every page
notebooks/[book]/
layout.tsx
handler.ts layout(): the notebook
[id]/
page.tsx /notebooks/:book/:id
handler.ts page() and head() for one noteimport { Missing, type Request } from 'ajo-kit'
import { db } from '../../database'
export async function layout(req: Request) {
const notebook = await db()
.selectFrom('notebooks')
.select(['id', 'title'])
.where('slug', '=', req.params.book)
.executeTakeFirst()
if (!notebook) throw new Missing('Notebook not found')
return { notebook }
}import { Missing, type Head, type Parent, type Request } from 'ajo-kit'
import { db } from '../../../database'
type Notebook = { id: number; title: string }
type Note = { id: number; title: string; text: string }
export async function page(req: Request, parent: Parent) {
const { notebook } = await parent() as { notebook: Notebook }
const note = await db()
.selectFrom('notes')
.select(['id', 'title', 'text'])
.where('notebook', '=', notebook.id)
.where('id', '=', Number(req.params.id))
.executeTakeFirst()
if (!note) throw new Missing('Note not found')
return { note }
}
export async function head(_req: Request, parent: Parent): Promise<Head> {
const { note } = await parent() as { note: Note }
return {
title: note.title + ' · Notes',
meta: [{ name: 'description', content: note.text.slice(0, 150) }],
}
}import type { PageArgs } from 'ajo-kit'
type Data = { note: { id: number; title: string; text: string } }
export default ({ data }: PageArgs<Data>) => !data ? null : (
<article>
<h1>{data.note.title}</h1>
<p>{data.note.text}</p>
</article>
)One data object per level
Route data is an array ordered from the outermost layout to the page, and each component receives its own entry. For /notebooks/work/42:
| Component | Receives as data |
|---|---|
src/layout.tsx | The result of layout() in src/handler.ts |
src/notebooks/[book]/layout.tsx | { notebook } |
src/notebooks/[book]/[id]/page.tsx | { note } |
Only directories with a layout.tsx take a place in the array; a layout() export in a directory without one never runs. A level with a component but no loader receives {}.
Ancestor data with parent()
parent() returns a promise of the merged data of every layout above the loader. The objects are merged shallowly from the outside in, so a deeper layout’s key replaces an outer key with the same name. In a layout() loader it covers the layouts above that one; in page() it covers all of them. If one of those loaders failed, parent() rejects with its error.
Use it where it removes a real duplicate read. Above, the notebook layout has already looked up and checked the notebook, so the page reuses its id instead of querying again. Reading through parent() sends nothing extra to the browser; each level returns only what its own component needs.
When loaders run
For each route request Kit:
- runs the middleware from every
wares.tson the path, outermost first; - starts every layout loader at once. A loader that awaits
parent()waits for the layouts above it; one that doesn’t runs independently; - runs the page loader once all layout loaders have resolved, so its
parent()resolves right away; - runs every
head()in parallel and merges the results.
The first loader that throws ends the request with its error. Because the page loader waits for the layouts, a slow layout loader delays every page below it; keep layout loaders to data that is truly shared.
Document head
type Head = {
title?: string
meta?: (
| { name: string; content: string }
| { property: string; content: string }
| { httpEquiv: string; content: string }
)[]
link?: { rel: string; href: string; [key: string]: string | undefined }[]
}Kit merges the heads of every layout, outermost first, and then the page:
- the last non-empty
titlewins; metaentries are keyed byname,propertyorhttpEquiv, andlinkentries byrel. A later entry with the same key replaces the earlier one in place;- entries with new keys accumulate.
In head(), parent() returns the data that the same level’s loader produced: the layout() result for a layout’s head, the page() result for the page’s head. A title can reuse what the loader already read, as the note’s head does above.
import type { Head, Request } from 'ajo-kit'
export function layout(req: Request) {
return { path: req.path }
}
export function head(): Head {
return {
title: 'Notes',
meta: [{ name: 'description', content: 'A small private notebook.' }],
link: [{ rel: 'icon', href: '/favicon.svg' }],
}
}For a note, the merged head has the note’s title and description and keeps the icon. On the server, Kit renders it into the ssr:head slot. On client navigation it sets document.title and updates or inserts the meta and link tags the new head names.
Throwing errors
Throw from a loader, head() or an action to stop and answer with a status:
| Class | Status | Default message |
|---|---|---|
Missing(message?) | 404 | Page not found |
Forbidden(message?) | 403 | Access denied |
Denied(message?) | 401 | Authentication required |
Invalid(fields, message?) | 400 | Validation failed; see Actions |
Failure(status, message) | Any |
All are exported from ajo-kit and extend Failure. Anything else is converted by normalize(): an error with a numeric status or statusCode between 400 and 599 keeps it, and everything else becomes a 500 that the server logs. The route then renders with error instead of data, as described in Routing.
Messages below 500 reach the browser as written, so write them for people. In production, messages of 500 and above are replaced with “Internal Server Error” in JSON responses and in the page state; the caution in Routing explains what that leaves to your layout.
Loaders do not redirect. A redirect decided before the loaders belongs in middleware: the guards in ajo-kit-auth answer a document request with a 302 and a navigation request with a { redirect } body, which the client follows. See API routes and middleware.
The request
Loaders, actions and middleware receive the same Request object, typed by ajo-kit.
| Field | Contents |
|---|---|
method | The HTTP method, upper-case. |
path | The path, without query string or fragment. |
originalUrl | The request target, with its query string. |
params | Decoded route parameters, as strings. |
query | The parsed query string. A repeated key becomes an array of strings. |
headers | Request headers, with lower-case names. |
body | The parsed JSON body, for actions and API handlers. |
read(limit) | Reads the raw body as bytes and fails with 413 past limit bytes. |
remoteAddress | The peer address. ip(req) resolves the client address, trusting forwarded headers only when TRUST_PROXY is set. |
user, session, token | Set by authentication middleware such as ajo-kit-auth. |
scope | An optional route-cache partition set by middleware; see Live updates. |
track(topic) | Records the live topics this route reads. Defined only while route loaders run, so call it as req.track?.('notes'). |
Other fields on the type are for Kit’s own use.
How data reaches the page
The same loaders serve two kinds of request.
A document request, such as a first visit or a reload, runs middleware and loaders, renders the route on the server and fills the slots of index.html: the merged head, the route state in a JSON script tag, and the markup. The client continues from that state without requesting the route again.
A navigation request comes from the client router. It fetches the same URL with Accept: application/json and receives an object with the route’s data array, its merged head, a hash, the tracked topics with their versions, and the cache scope.
The hash is a digest of the route’s head and data. The client keeps recently visited routes in a small in-memory cache (50 entries, for five minutes) and sends the hash back in an X-Have header when it returns to one. If the loaders produce the same payload, the server answers 304 Not Modified without a body. When the route tracks topics and none has changed since, the server can answer 304 without running the loaders at all; middleware still runs first. Route responses carry Cache-Control: no-store and Vary: Accept, Cookie. Live updates covers topics, versions and the cache scope.
- ActionsHandle writes and validate input.
- Live updatesTrack topics and refresh routes when data changes.
- DatabaseQuery SQLite through Kysely.
Source of truth: ajo-kit README