Routing & Rendering
Routes live in src/app/ as a file-based tree. Directory nesting maps to URL nesting, and
special filenames define what each segment renders.
Route files
| File | Role |
|---|---|
page.tsx |
The page. Default export is the Page component; props { params, searchParams, data?, actionData? }. |
layout.tsx |
Wraps its subtree: ({ children, route }) => Renderable, where route is { pathname, host }. Nests by directory. The root layout owns <html>. |
route.ts |
HTTP handler. Export GET / POST / PUT / PATCH / DELETE / OPTIONS / HEAD, each (request, { params }). See "API routes" below. |
loading.tsx |
Suspense fallback for the segment. |
error.tsx |
Error boundary: ({ error, reset, route }). |
not-found.tsx |
Rendered inside the layouts when a loader or action returns or throws a 404 Response, or throws notFound(). |
template.tsx |
Like a layout, wrapping the page inside the layouts. |
For loading.tsx, error.tsx, not-found.tsx and template.tsx, the closest file walking up
from the page's directory applies. A page file may also export data functions (loader /
action); see Loaders & Actions.
A directory whose name starts with _ or . is never routed: use it for components or utilities
that belong to a segment.
The Page component
The default export of page.tsx receives:
/** @jsxImportSource @bext-stack/framework */
export default function Page({ params, searchParams, data, actionData }) {
return <h1>Hello {params.slug}</h1>;
}
searchParams maps each query parameter to a string, or to an array of strings when it repeats.
The Page never sees the raw request: only loader and action do. Read cookies and
headers there and pass what you need down via data / actionData.
API routes
A route.ts under src/app/api/ answers the matching URL: src/app/api/posts/route.ts answers
/api/posts, src/app/api/posts/[id]/route.ts answers /api/posts/42.
// src/app/api/posts/[id]/route.ts
export async function GET(request, { params }) {
return { id: params.id }; // sent as JSON, status 200
}
export async function POST(request, { params }) {
const body = await request.json();
return new Response(JSON.stringify({ id: params.id, ...body }), {
status: 201,
headers: { "content-type": "application/json" },
});
}
request reads like a Web Request: method, url, headers.get(), json(), text(),
formData() (urlencoded and multipart) and arrayBuffer().
- A method without an export gets a
405with{ "error": "method <METHOD> not allowed" }, unless the file has a default export, which then handles every method. - Returning a
Responsesends it as is. Returning a{ status, headers, body }object built by the response helpers (see below) is sent too. Any other value is sent as a200JSON response. - An uncaught error becomes a
500JSON response{ "error": "<message>" }. - A URL under
/api/that noroute.tscovers returns a 404, without trying the pages.
A route.ts placed outside src/app/api/ (for example src/app/feed.xml/route.ts) is a
fallback: it answers only when no page matches the URL, and only for the methods it exports.
Any other method gets a 404, not a 405. A route.ts next to a
page.tsx is ignored: the page keeps the URL.
Dynamic, catch-all, groups, and parallel routes
| Pattern | Meaning | Example URL |
|---|---|---|
[slug] |
Dynamic segment → params.slug |
/posts/hello |
[...slug] |
Catch-all → params.slug, the rest of the path as one string ("a/b/c") |
/docs/a/b/c |
(group) |
Grouping only, no effect on page URLs | (marketing)/posts → /posts |
@name/page.tsx |
Named parallel-route slot | passed to the layout as the name prop |
Precedence is decided segment by segment, left to right: a literal segment beats [param], which
beats [...rest]. The route that says the most about the start of the URL wins. A catch-all also
answers its own prefix (docs/[...slug] answers /docs with params.slug === ""), but only when
no other route matches exactly.
A parallel slot's page receives the same props as the page, is rendered to HTML and handed to the
deepest layout as a prop named after the slot (for @modal, the layout reads
({ children, modal })). A slot that fails to render is left empty.
Next.js intercepting routes ((.)folder, (..)folder) are not supported: the pages inside them
are not routed.
Metadata
Add head tags with a metadata export, or generateMetadata() when they depend on the URL:
export const metadata = {
title: "Posts",
description: "All posts",
};
// or, dynamic:
export async function generateMetadata({ params, searchParams, route, status }) {
const post = await getPost(params.slug);
return { title: post.title };
}
generateMetadata receives { params, searchParams, route, status }, not the loader's data.
route is { pathname, host }; status is 200 for a normal render, 404 when rendering
not-found.tsx and 500 when rendering error.tsx.
Layouts can export metadata or generateMetadata too. Levels are merged from the root layout down
to the page: for each key the deepest level wins, and an object (openGraph, robots...) is
replaced whole, not merged. title takes a string or { default, template, absolute }: a layout
that sets template: "%s | My site" formats the titles of the levels below it. For a 404 or an
error, only the layouts provide metadata.
When one file exports both, the static metadata wins. If generateMetadata throws, that level is
skipped and the page still renders with the other levels' metadata.
Static icon.*, apple-icon.*, opengraph-image.* and twitter-image.* files placed in
src/app/ (or a subdirectory) are served at the matching URL, and the corresponding <link> or
<meta> tag is added to the head of the pages below them. sitemap.xml and robots.txt at the
root of src/app/ are served as is.
Rendering modes
The site-wide rendering mode is set in bext.config.toml:
[rendering]
mode = "isr" # only "isr" turns the page cache on
revalidate = 60 # seconds, 60 when omitted
- Without
mode = "isr", every request is rendered. isr: incremental static regeneration. The rendered output is cached and revalidated afterrevalidateseconds; for a short window after expiry, the stale copy is served while a fresh render runs in the background.
On an ISR site, a route can set its own cache lifetime with export const revalidate = 30
(seconds), and its stale window with export const swr = 120 (seconds, applied together with
revalidate).
Under ISR, requests that carry a session cookie or an Authorization header are neither served
from nor stored in the page cache, so signed-in visitors always get a fresh render.
Whatever the mode, a loader's result is also cached for up to 15 seconds for GET requests that
carry no cookie at all, when the loader reads no request headers and returns a JSON-serialisable
value. Opt a route out with force-dynamic (below) when it must always be current, for example a
page reached through a tokenised link.
Opting out of caching
export const dynamic = "force-dynamic";
// or
export const revalidate = false;
Either export opts the route out of the page cache and of the automatic loader cache
(revalidate = 0 also opts the route out of the loader cache).
Streaming
A route streams its HTML when any of these hold:
- it imports
Suspensefrom@bext-stack/framework/streaming, - it has a
loading.tsxnext to itspage.tsx, - its default export is an async generator (
export default async function* Page()), or - it exports
export const renderingMode = "streaming".
export const renderingMode = "buffered" turns streaming off for a route that would otherwise
stream. force-dynamic does not turn streaming off, only caching.
A loader or action that redirects is still honoured: the response is only committed once the loader and action have run.
Response helpers
The framework exports helpers that build plain response objects ({ status, headers, body }):
import { json, html, text, xml, redirect, notFound } from "@bext-stack/framework";
| Helper | Signature |
|---|---|
json |
json(data, status = 200) |
html, text, xml |
html(body, status = 200), and the same for text and xml |
redirect |
redirect(location, status = 302), status one of 301, 302, 307, 308 |
notFound |
notFound(body = "Not Found") |
An API route (route.ts) can return these objects directly. They are not Response
instances: in a loader or action, throw them (throw redirect("/login")) to short-circuit the
render; returned, they simply become data or actionData. See
Loaders & Actions.
To drop cached entries by tag, use revalidateTag from the cache entry point:
import { revalidateTag } from "@bext-stack/framework/cache";
revalidateTag("products"); // returns the number of entries dropped
Where to next
| Page | What it covers |
|---|---|
| Loaders & Actions | Loading data, mutations, request/session access |
| SDK Overview | Site layout, the createSdk client, vendoring |
| CLI Reference | bext dev, bext build, bext routes |
| Config Reference | bext.config.toml |