Routing file conventions
Swift Rust uses file‑system routing. Beyond page and layout, a set of special files co‑located in a route segment add data loading, access control, mutations, caching, SEO, and more. Every file is optional and additive — add one when you need it.
Routing files live under your app root —src/app/when you use asrc/directory, otherwiseapp/. The dev server warns at startup if it finds a routing file placed where it won't be picked up.
Execution order
For each matched route, files run in a fixed pipeline (all optional):
runtime (config / edge / worker)
→ proxy (rewrites, headers, redirects — cheap, no data)
→ schema / query (validate + type params & searchParams; may 400)
→ guard (auth / roles / flags; outer → inner; may 401 / 403 / redirect)
→ action (mutations, on POST/PUT/PATCH/DELETE) ┐
→ loader + state (data, in parallel along the chain) ├ then render
→ render (shell → layout → template → page) ┘
→ seo / stream (head & JSON‑LD; custom streaming)
→ revalidate (cache TTL + tags)
on throw → error → error-recovery → not-found → global-errorFiles compose outermost‑first: a guard or proxy in app/dashboard/ runs for every route beneath it. A proxy.ts at the app root runs for every request.
Existing files
page.tsx— the route's UI.layout.tsx— shared UI that wraps child routes.loading.tsx— Suspense fallback during the initial stream.error.tsx·global-error.tsx— error boundaries.not-found.tsx— 404 UI.template.tsx·default.tsx— re‑mounting wrapper / parallel‑slot default.route.ts— API route handler (GET/POST/…).
guard.ts — access control
Runs before loaders and actions. Allow by returning nothing; deny with redirect(), unauthorized() (401), forbidden() (403), or notFound(). Pass data to downstream files via locals.
Guards are opt‑in. A guard.ts only runs when you enable it — keeping protection explicit. Three ways, any one is enough:
- a
"use guard"directive at the top ofpage.tsx,layout.tsx, orconfig.ts(applies to that tree), config.ts→{ guard: true }for a segment, orswift-rust.config.json→{ autoApplyGuard: true }to enforce every guard project‑wide (secure‑by‑default; defaults tofalse).
'use guard'; // ← drop this in and admin/guard.ts is enforced
export default function Admin() { … }import { redirect, forbidden } from "swift-rust/router";
export default async function guard(ctx) {
const session = await getSession(ctx.cookies.get("sid"));
if (!session) return redirect("/login?next=" + ctx.url.pathname);
if (!session.user.isAdmin) return forbidden();
ctx.locals.set("user", session.user);
}loader.ts — data loading
Fetches the data a segment needs. Loaders run in parallel along the chain; read the result in your page with useLoaderData<typeof loader>().
import { notFound } from "swift-rust/router";
export default async function loader(ctx) {
const post = await db.post.find(ctx.params.slug);
if (!post) notFound();
return { post };
}
export const cache = { revalidate: 60, tags: ["posts"] };import { useLoaderData } from "swift-rust/router";
import loader from "./loader";
export default function Page() {
const { post } = useLoaderData<typeof loader>();
return <Article post={post} />;
}action.ts — mutations
Handles non‑GET requests (form posts, writes). Validate with schema.ts; redirect() on success (POST‑redirect‑GET). Read the result with useActionData().
import { redirect } from "swift-rust/router";
import { PostSchema } from "./schema";
export default async function action(ctx) {
const form = await ctx.formData();
const parsed = PostSchema.safeParse(Object.fromEntries(form));
if (!parsed.success) return { errors: parsed.error.flatten() };
const post = await db.post.create(parsed.data);
return redirect(`/posts/${post.slug}`);
}schema.ts — typed inputs
The source of truth for params, searchParams, and form data. Any Standard‑Schema library works (Zod, Valibot). Validated values flow to every file in the segment.
import { z } from "zod";
export const params = z.object({ slug: z.string().min(1) });
export const searchParams = z.object({ page: z.coerce.number().default(1) });proxy.ts — request interception
Cheap, data‑free interception scoped to a subtree: rewrites, headers, redirects, A/B buckets. (Formerly middleware.ts, which still works with a warning.) Limit paths with matcher.
import { rewrite } from "swift-rust/router";
export default function proxy(ctx) {
if (ctx.url.pathname === "/old") return rewrite("/new");
}
export const matcher = ["/old", "/blog/**"];config.ts — route configuration
Static per‑segment config (merged inner‑over‑outer). Headers and cache settings are applied to the response; runtime selects where the segment executes.
export const config = {
runtime: "bun", // "bun" (default) | "edge" | "node" | "worker"
rendering: "ssr-stream",
revalidate: 120, // seconds
headers: { "X-Frame-Options": "DENY" },
};Runtimes — bun · edge · node
Every route declares a runtime. Bun is the default (fast, global). Force a different one with a top‑of‑file directive — it applies to the file and its tree, just like "use client":
'use bun'; // ← or 'use edge' / 'use node'
export default function Dashboard() {
return <main>…</main>;
}Or set a default per‑segment in config.ts, or project‑wide in swift-rust.config.json. Resolution order, highest priority first:
'use bun' | 'use edge' | 'use node' // file directive — wins
→ config.ts { runtime: 'edge' } // per-segment default
→ swift-rust.config.json "runtime" // project default
→ 'bun' // built-in defaultThe resolved runtime is exposed to every routing file as ctx.runtime and emitted on the response as x-swift-rust-runtime. edge.ts / worker.ts remain a file‑based way to force the Edge / Workers runtime.
What each runtime builds to. A route that explicitly opts into a runtime (a directive or config.ts runtime) is emitted as a request‑time Vercel Function on that runtime — its full pipeline (proxy, schema, guard, action, loader, state, seo) runs per request:
bun— a Bun function (the build setsvercel.json→"bunVersion": "1.x").node— a Node.js serverless function.edge— a Vercel Edge function.
Routes with no explicit runtime keep the default: prerendered to static HTML served from the global edge CDN (the fastest path). So bun is the default for request‑time routes, and static is the default for everything else.
revalidate.ts — cache control
Decides cache TTL and tags per request/mutation. Emits Cache-Control and cache tags for on‑demand invalidation.
export default function revalidate(ctx) {
return { ttl: 300, tags: ["post:" + ctx.params.slug] };
}seo.tsx — head & structured data
Runs with loader data; emits title, description, canonical, OpenGraph, and JSON‑LD into <head>.
export default function seo(ctx) {
return {
title: ctx.data.post.title,
description: ctx.data.post.excerpt,
canonical: "https://example.com/posts/" + ctx.params.slug,
jsonLd: { "@context": "https://schema.org", "@type": "Article", headline: ctx.data.post.title },
};
}state.ts — client store hydration
Seeds a client store with server data; serialized to window.__SR_STATE__.
export default function state(ctx) {
return { user: ctx.locals.get("user"), theme: ctx.cookies.get("theme") ?? "light" };
}rpc.ts — typed procedures
Co‑located, validated procedures callable over POST; GET lists them.
import { z } from "zod";
export const procedures = {
search: {
type: "query",
input: z.object({ q: z.string() }),
handler: (input, ctx) => db.search(input.q),
},
};
// POST /search { "procedure": "search", "input": { "q": "rust" } }stream.ts — streaming responses
A dedicated streaming endpoint (SSE, chunked JSON, token streams) for a route without a page.
export const contentType = "text/event-stream";
export default function stream(ctx) {
return new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode("data: hello\n\n"));
c.close();
},
});
}edge.ts / worker.ts — runtime targeting
Force a segment onto the Edge or a Workers runtime. Equivalent to config.runtime with runtime‑specific options.
export const edge = { regions: ["iad1", "fra1"] };variant.tsx — A/B variants
Render a variant of a segment based on a bucket (from proxy, a cookie, or assign).
import A from "./home-a";
import B from "./home-b";
export const variants = {
a: async () => ({ default: A }),
b: async () => ({ default: B }),
};
export const assign = (ctx) => (ctx.cookies.get("exp") === "b" ? "b" : "a");i18n.ts — locale resolution
Resolves the active locale (cookie, Accept-Language, or a custom resolver) into locals.locale before render.
export const i18n = {
locales: ["en", "fr", "de"],
defaultLocale: "en",
strategy: "cookie",
};error-recovery.tsx — retry UI
A richer error boundary with retry/reset, used after error.tsx.
"use client";
export default function ErrorRecovery({ error, retry, attempt }) {
return (
<div role="alert">
<p>Something went wrong: {error.message}</p>
<button onClick={() => retry()}>Retry ({attempt})</button>
</div>
);
}Control‑flow helpers
Exported from swift-rust/router and usable from guard, loader, action, and proxy:
redirect(to, status?)·permanentRedirect(to)·rewrite(to)notFound()·unauthorized()(401) ·forbidden()(403)
Client navigation
<Link> and plain <a> links to same‑origin routes are upgraded to client‑side navigation automatically: the next page is fetched, the body is swapped, scripts re‑run, <title> and meta are synced, and back/forward history is managed — no full reload. It degrades to ordinary links when JavaScript is off.
prefetch.ts — link prefetching
Controls how links are prefetched into the navigator's cache. The nearest prefetch.ts up the tree wins; opt a single link out with <Link prefetch={false}>.
export const strategy = "viewport"; // "hover" (default) | "viewport" | "none"
export const margin = "300px"; // IntersectionObserver rootMarginpending.tsx — navigation pending UI
Shown while a client navigation is in flight — but only once it outlasts a ~120ms threshold, so fast/cached navigations don't flash it. Rendered into a fixed overlay; style it as a top progress bar or spinner.
export default function Pending() {
return <div className="route-progress" />;
}transition.tsx — view transitions
Wraps the navigation swap in the View Transitions API. Falls back to a plain swap when the API is unsupported, type is "none", or the user prefers reduced motion.
export const type = "slide"; // "fade" (default) | "slide" | "none"
export const duration = 250; // msParallel routes — @slots
A layout directory can hold named slot folders (@modal, @sidebar). Each slot resolves against the URL independently and is passed to the layout as a prop alongside children.
export default function Layout({ children, modal }) {
return <>{children}{modal}</>;
}@modal/page.tsx·@modal/fragment.tsx— the slot's matched leaf (usefragment.tsxfor reusable modal/drawer content).@modal/default.tsx— rendered when the slot has no match for the current URL.@modal/fallback.tsx— Suspense fallback while the slot loads.
shell.tsx — the outer document
A root‑only file that owns the outer document — <html>, <body>, and top‑level providers. The framework still injects its head assets (metadata, fonts, global CSS, the client navigator) into your <head>, and client scripts before </body>. Use it for app‑wide context providers or a custom document structure; with a shell, your root layout.tsx returns a fragment rather than its own <html>.
export default function Shell({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head />
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}