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 405 with { "error": "method <METHOD> not allowed" }, unless the file has a default export, which then handles every method.
  • Returning a Response sends 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 a 200 JSON response.
  • An uncaught error becomes a 500 JSON response { "error": "<message>" }.
  • A URL under /api/ that no route.ts covers 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 after revalidate seconds; 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).

Info

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 Suspense from @bext-stack/framework/streaming,
  • it has a loading.tsx next to its page.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