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 a src/ directory, otherwise app/. 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):

lifecycle
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-error

Files 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 of page.tsx, layout.tsx, or config.ts (applies to that tree),
  • config.ts{ guard: true } for a segment, or
  • swift-rust.config.json{ autoApplyGuard: true } to enforce every guard project‑wide (secure‑by‑default; defaults to false).
app/admin/page.tsx
'use guard';   // ← drop this in and admin/guard.ts is enforced

export default function Admin() { … }
app/dashboard/guard.ts
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>().

app/posts/[slug]/loader.ts
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"] };
app/posts/[slug]/page.tsx
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().

app/posts/new/action.ts
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.

app/posts/[slug]/schema.ts
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.

app/proxy.ts
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.

app/dashboard/config.ts
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":

app/dashboard/page.tsx
'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:

priority
'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 default

The 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 sets vercel.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.

app/posts/[slug]/revalidate.ts
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>.

app/posts/[slug]/seo.tsx
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__.

app/dashboard/state.ts
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.

app/search/rpc.ts
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.

app/chat/stream.ts
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.

app/(edge)/ping/edge.ts
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).

app/home/variant.tsx
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.

app/i18n.ts
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.

app/dashboard/error-recovery.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.

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}>.

app/prefetch.ts
export const strategy = "viewport"; // "hover" (default) | "viewport" | "none"
export const margin = "300px";        // IntersectionObserver rootMargin

pending.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.

app/pending.tsx
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.

app/transition.tsx
export const type = "slide";  // "fade" (default) | "slide" | "none"
export const duration = 250;    // ms

Parallel 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.

app/dashboard/layout.tsx
export default function Layout({ children, modal }) {
  return <>{children}{modal}</>;
}
  • @modal/page.tsx · @modal/fragment.tsx — the slot's matched leaf (use fragment.tsx for 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>.

app/shell.tsx
export default function Shell({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head />
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}