Skip to content

App

Server router

The routing model of SvelteKit as a typed, programmatic table. Pages, layouts, loads, actions, endpoints in one file. App['/path'] gives each component its data, typed, with no codegen.

On this page

ogygia/router is the routing model of SvelteKit, expressed as values instead of a file tree. One ordinary module declares the full route table: pages, layouts, loads, actions, endpoints. The types thread through it. Thus each component reads its data, params, and form fully typed, with no codegen and no $types import.

It runs on the server: it matches the request, runs the load cascade, and renders the page through its layout chain. It is the counterpart to the SPA router, which navigates between those pages on the client. Use either alone. Together, the server router defines routes, and the SPA router moves between them.

All that a Kit user knows carries over: +layout.svelte loads, the data cascade, page.data, +error.svelte, form actions, redirect/error, $app/state, $app/navigation. The one difference: each concept is an imported function, not a magic filename.

When to use

Use it when your routes are computed: a CMS whose paths come from a database, a tool whose pages are generated, a package that ships its own routes. Or when you want the full route table, its loaders, and its components in one place, not scattered across +page.svelte / +page.server.ts / +server.ts. For a few ordinary pages, the file routes of Kit are simpler. This is not a replacement that you must adopt.

The whole thing in one file

import { routes, page, layout, load, redirect, error, fail } from 'ogygia/router';
import AppShell from '$lib/views/AppShell.svelte';
import Home from '$lib/views/Home.svelte';
import Doc from '$lib/views/Doc.svelte';
import Login from '$lib/views/Login.svelte';
import ErrorPage from '$lib/views/ErrorPage.svelte';
import { docs, auth } from '$lib/server';

// A layout is a NAMED table→table wrapper: its component, its load, its error boundary.
const app = layout('app', AppShell, {
  load: async (c) => ({ user: await auth.user(c.cookies) }),
  error: ErrorPage,
});

export const router = routes(
  app({
    '/':            page(Home),
    '/docs/[slug]': page(Doc, {
      load: async (c) => {
        const doc = await docs.get(c.params.slug);   // c.params.slug: string, from the pattern
        if (!doc) error(404, 'No such doc');          // throws — renders the nearest error page
        return { doc: await doc.body, title: doc.data.title };
      },
    }),
    '/login': page(Login, {
      actions: {
        default: async (c) => {
          const form = await c.request.formData();
          if (!(await auth.login(form))) return fail(400, { incorrect: true });
          redirect(303, '/');
        },
      },
    }),
    '/api/[id]': { GET: (c) => c.json(docs.raw(c.params.id)) },   // an endpoint — the +server.ts shape
  }),
);

export type App = typeof router.$infer;   // the ONE type map components index

That is the full surface. The rest of this page is each piece in detail.

Pages

A page is a Svelte component plus its +page.server.ts-shaped server object:

'/':            page(Home),                              // no server logic
'/docs/[slug]': page(Doc, { load: fn }),                 // a load
'/login':       page(Login, { actions: { default } }),   // form actions
'/edit/[id]':   page(Editor, { load, actions, params, search }),   // all of it

The second argument mirrors +page.server.ts exactly: load, actions, plus the input schemas params and search (see Typed input). It is always an object. Write a bare page(Home) when there is nothing to declare.

A routed page is inert: it renders as csr = false server HTML, with zero JavaScript. Interactivity comes from marked island imports inside the component (with { wake }), exactly as in all other parts of ogygia. See Pages carry region dials to wake or defer the page itself.

Layouts

layout(name, Component, { load?, error? }) returns a table→table wrapper. Apply it to a sub-table, and each page inside renders within that layout:

const app = layout('app', AppShell, { load: async (c) => ({ user: await user(c) }) });

routes(
  app({
    '/':       page(Home),
    '/about':  page(About),
  }),
);

The layout component is a Kit layout: { data, children }, {@render children()}:

<script lang="ts">
  import type { App } from '$lib/router';
  let { data, children }: App['(app)'] = $props();
</script>
<nav>signed in as {data.user.name}</nav>
{@render children()}

Nesting

A nested layout is a wrapped sub-table spread into another. A page inside both wraps renders inside both, and its data merges both loads:

const app  = layout('app',  AppShell,  { load: loadUser });
const dash = layout('dash', DashShell, { load: loadTeam });

routes(
  app({
    '/':  page(Home),                                 // AppShell
    ...dash({
      '/dashboard': page(Dashboard),                  // AppShell › DashShell, data = { user, team }
    }),
  }),
);

Breaking out of a layout

Kit resets a page to a named ancestor with +page@name.svelte (or +page@.svelte for the root). Here it is placement: you put the route in the table, wrapped by exactly the layouts that you want. More explicit: the reset point is the structure, not a filename annotation.

routes(
  app({
    ...dash({
      '/dashboard':          page(Dashboard),   // app › dash
    }),
    '/dashboard/settings':   page(Settings),    // app only     — reset out of `dash`  (Kit's @(app))
  }),
  '/login':                  page(Login),        // no chrome    — reset to root         (Kit's @)
);
Kitogygia/router
+layout-foo.svelte (a named layout)layout('foo', Foo)
+page@foo.svelte (reset to foo)place the route inside foo(…) but outside the inner wraps
+page@.svelte (reset to root)place the route outside all wraps

Loads and the data cascade

A load is a function of c that returns the data of the page. Declare it inline, pull it out with the Load<'/pattern'> type helper, or wrap it with load() when it must be shared across loads:

// inline
page(Doc, { load: async (c) => ({ doc: await docs.get(c.params.slug) }) }),

// pulled out, typed by the pattern
import type { Load } from 'ogygia/router';
const docLoad: Load<'/docs/[slug]'> = async (c) => ({ doc: await docs.get(c.params.slug) });
page(Doc, { load: docLoad }),

The cascade is the one from Kit, verbatim. The data of a page is the merge of each load on its branch: each layout, then the page, outermost first, last wins. A component reads the full merge:

<script lang="ts">
  import type { App } from '$lib/router';
  let { data }: App['/dashboard'] = $props();
  //   data.user   — from the app layout's load
  //   data.team   — from the dash layout's load
  //   data.stats  — from this page's load
</script>

page.data (through $app/state) is that same merged object. Thus a component in any location reads page.data.user: exactly where a Kit user looks for it.

Sharing without waterfalls

Loads on one branch run concurrently by default (the behavior of Kit). When one load truly needs the value of another, wrap the shared one with load(), and await it. It runs one time per request (memoized). Thus there is no double fetch:

const session = load(async (c) => ({ user: await auth.user(c.cookies) }));

const app   = layout('app',   AppShell, { load: async (c) => await session(c) });
const admin = layout('admin', AdminShell, {
  load: async (c) => {
    const { user } = await session(c);       // shared with the app layout — runs ONCE
    if (!user.admin) redirect(303, '/login');
    return { user };
  },
});

This is the await parent() of Kit, generalized and typed. Sequencing exists exactly where a real data dependency exists, and in no other place. Waterfall and waste are both structurally impossible.

Form actions

Actions are the form actions of Kit: a POST handler that reads FormData itself. fail(status, data) returns validation data to the typed form prop. redirect() throws on success:

page(Login, {
  actions: {
    default: async (c) => {
      const form = await c.request.formData();
      const user = await auth.login(form);
      if (!user) return fail(400, { email: form.get('email'), incorrect: true });
      redirect(303, '/dashboard');
    },
    logout: async (c) => { await auth.logout(c); redirect(303, '/'); },
  },
});

Named actions use the ?/name URL convention of Kit (default is the unnamed one). The form prop is typed from the returns of the actions:

<script lang="ts">
  let { data, form }: App['/login'] = $props();
</script>
{#if form?.incorrect}<p>Wrong email or password.</p>{/if}

Progressive enhancement rides the form continuity of ogygia. No use:enhance import is needed on a csr = false page.

Endpoints

An endpoint is the +server.ts shape, written as an object of uppercase verb handlers:

'/api/[id]': {
  GET:    (c) => c.json(read(c.params.id)),
  DELETE: (c) => { remove(c.params.id); return new Response(null, { status: 204 }); },
},

c.params is typed from the table key: a bare handler reads c.params.id, with no pattern restated. When you need typed params in a pulled-out handler, or a body schema, use the exported verb wrappers GET / POST / PUT / DELETE / PATCH: the same “bare or wrapper” choice as load()/action():

import { POST, type Handler } from 'ogygia/router';

// body schema on POST/PUT/PATCH → validated JSON body into c.input (400 on bad input)
'/posts': { POST: POST(NewPostSchema, (c) => c.json(create(c.input))) },

// pulled out + typed by the pattern
const readPost: Handler<'/posts/[id]'> = (c) => c.json(read(c.params.id));
'/posts/[id]': { GET: readPost },

GET/DELETE take no body schema (their input is path params + query, already typed). HEAD and OPTIONS are handled automatically from the verb set. A wrong method 405s, with an Allow header.

The context c

Each load, action, and handler receives c: the request surface of Kit:

c memberwhat
c.paramspath params, typed from the pattern (or the output of the params schema)
c.searchquery params: Record<string, string>, or the typed output of the search schema
c.inputthe validated JSON body of a POST/PUT/PATCH body schema
c.url / c.request / c.route.idthe request
c.cookies / c.locals / c.platform / c.setHeaders / c.getClientAddressKit RequestEvent pass-throughs (present when mounted in Kit)
c.fetchthe cookie-forwarding fetch of Kit; same-origin requests resolve in-process
c.json() / c.redirect() / c.text()Response shortcuts
c.href('/docs/[slug]', { slug })a rename-safe URL to a route in this table
c.statea per-request scratch bag (a guard writes, a handler reads)
c.visitorTHE identity, resolved one time, lazily (below)

One identity: c.visitor

Declare who the request is one time, on the table, and read it in all locations:

routes(table, {
  visitor: (c) => session_from(c.cookies),   // → { sub: 'u123', roles: [...] } or undefined
});

c.visitor is lazy and memoized. The resolver runs at most one time per request, and only when something reads it. Experiments stick on it. Mounts sign it onward to other teams. Loads personalize with it. When the request arrived through a signed federation hop, the signature-bound claims of the upstream win over the local resolver. Proof beats config.

Control flow: redirect, error, fail

Thrown from a load, action, or handler. The exact names of Kit, imported from ogygia/router:

import { redirect, error, fail } from 'ogygia/router';

redirect(303, '/login');            // → a redirect Response
error(404, 'Not found');            // → the nearest error boundary (page) / JSON (endpoint)
return fail(400, { invalid: true }); // action only — re-render with `form` = data, given status

A thrown error() on a page branch renders the nearest layout error component, inside the chrome that survived (the +error.svelte semantics of Kit). Set the root boundary with routes(table, { error: ErrorPage }). Scope one to a subtree with layout('name', C, { error }). On an endpoint, error() becomes a JSON response.

Typed input

Path params are strings. A Standard Schema (Valibot, Zod, ArkType, …) coerces and validates them. The coerced type flows into App. This is truly beyond Kit, and fully opt-in. With no schema, params is pattern-typed exactly as in Kit.

import * as v from 'valibot';

'/post/[id]': page(Post, {
  params: v.object({ id: v.pipe(v.string(), v.transform(Number), v.number()) }),  // "abc" → 404
  search: v.object({ page: v.optional(v.number(), 1) }),                          // ?page=x → 400
  load: async (c) => ({ post: await posts.get(c.params.id) }),   // c.params.id: number, c.search.page: number
}),
  • params: coerce/validate path params. Garbage → 404 (it is a nonexistent resource). App['/post/[id]']['params'] becomes the output of the schema.
  • search: type the query. Bad input → 400, or lenient with schema defaults. App['/post/[id]']['search'] too.
  • endpoint body: POST(schema, fn) validates the JSON body into c.input. A bad body → 400. Like the typed argument of a command remote function.

Typed everything: $infer

typeof router.$infer is a phantom-typed map. It exists only at the type level (a runtime read throws; always write typeof router.$infer). Export it one time. Components index it:

export type App = typeof router.$infer;
  • App['/docs/[slug]'] → { data, form, params, search } for that page
  • App['(app)'] → { data, children } for the app layout
<script lang="ts">
  import type { App } from '$lib/router';
  let { data, form, params }: App['/docs/[slug]'] = $props();
</script>

It is computed from the loads and schemas, never from component types. Thus there is no cycle and no codegen. Layout keys use the group spelling of Kit ((app)). Path keys always start with /. Thus the two never collide. Duplicate layout names are a build error.

No `$types`, no cast

This replaces the generated ./$types of Kit. The component declaration App['/path'] is the check: props are defined as the type of the route. Change the return of a load, and the component squiggles: exactly the flow of Kit, with zero generated files.

Typed API client

The table already knows the path grammar, body schema, and payload of each endpoint. api() gives that to a consumer, as a typed fetch client:

export type App = typeof app.$infer;                 // the map the app already exports

import { api } from 'ogygia/router/client';           // browser-safe (islands can import it)
const svc = api<App>('https://svc.internal');

const post = await svc.get('/posts/[id]', { params: { id: '1' } });   // payload: typed
await svc.post('/posts', { body: { title } });                        // body: schema-typed
  • Plain returns are the payload. GET: (c) => ({ id: c.params.id }) serializes to JSON (finalize()). Thus its type flows to the caller. A handler that returns a raw Response types as unknown: its payload is sealed inside.
  • Paths are constrained to keys that carry the verb (page paths never do), params is required exactly when the pattern has some, and a POST(schema, fn) body types from the schema.
  • Non-2xx throws ApiError with the status and the parsed error body (a schema 400 carries its issues).
  • Pass { headers } for auth on each call, and { fetch } for the cookie-forwarding fetch of a Kit event, or a test stub.

Pages carry region dials

A routed page is only a component. Thus its wake / render schedule rides as an import attribute on the component that you pass to page(): the same dials as a marked island import:

import Chart from '$lib/views/Chart.svelte' with { wake: 'visible' };
import Greeting from '$lib/views/Greeting.svelte' with { render: 'deferred' };

'/dashboard': page(Chart),                              // wakes when scrolled into view
'/':          page(Greeting, { fallback: Skeleton }),   // deferred page + its loading slot

render: 'deferred' gives a partially-prerendered page from the route table: a static shell, with a per-visitor hole, and the fallback snippet as its loading UI. See held regions and server islands.

A dynamic pattern prerenders when it declares its param sets: the per-route entries export of Kit, as a page option:

'/posts/[id]': page(Post, {
  entries: async () => (await posts.refs()).map((p) => ({ id: p.id })),
  load: (c) => ({ post: posts.get(c.params.id) }),
}),

Each set fills the pattern into the entries() crawl list of the router. A dynamic page without them is crawled, or SSR’d live, as before.

Streamed pages: yield regions

“The next state of a slot is a region” already powers live regions. The same vocabulary operates on the initial response. A page slot can be an async generator. The first yield renders, and the page flushes immediately. Each later yield rides down the same response, and swaps into place as it arrives. One connection, closed when the generator ends.

'/report': page(async function* (c, data) {
  yield '<div class="skeleton">crunching…</div>';        // flushes NOW
  yield region(Report, { data: await slow_numbers(c) }); // swaps in when ready
}),
  • Yield regions, or raw html strings. A component region bakes with its islands inside. Islands are self-waking custom elements. Thus they connect and hydrate the moment that the late chunk lands. No orchestration layer.
  • Several late yields apply in order. Progressive states are free.
  • A throw after the flush becomes an inline error card in the slot. The page always closes cleanly.
  • The honest trades: status, title, and headers go out with the first yield. A late failure cannot change an already-sent 200, and setHeaders after the flush is lost. Non-GET requests buffer to the final yield (actions need a full answer).
  • Streamed responses carry Cache-Control: no-transform. A compressing intermediary buffers while it gzips, which would collapse the early flush into one late paint. When your CDN compresses regardless, exempt these routes (or accept buffered delivery there).
  • A component that you yield from a server-only module needs a mark (with { region: 'raw' }, or a wake). A plain import has no client leg. Thus its region cannot bake. A mistake here fails loudly, into the error card of the slot.

Streamed load data needs no generator at all. Give a promise of a region from the load to a <Region of={…}>, and the region resolves down the initial response: a placeholder in the flushed document, the baked region as a completion-order chunk, and the islands inside wake on adoption. Put as many such holes on one page as you want. Each fails into its own error card.

'/report': page(Report, {
  load: () => ({ pending: (async () => region(Numbers, await slow()))() }),
}),
<Region of={data.pending}>
  {#snippet placeholder()}<div class="skeleton" />{/snippet}
</Region>

(This rides the request scope of the ogygia handle: standard wiring. A rejection becomes the error card of that slot, and never touches its neighbors.)

Flag-gated routes: when()

Wrap a table entry (a page, an endpoint object, a mount()), and it exists only when the gate says so:

import { when } from 'ogygia/router';
import { checkoutV2, exportsFlag, cmsRollout } from '$lib/flags';

routes({
  '/checkout-v2': when(checkoutV2, page(NewCheckout, { load })),  // staged rollout of a whole page
  '/api/export':  when(exportsFlag, { GET: GET(export_csv) }),    // endpoint kill switch
  '/cms/[...rest]': when(cmsRollout, mount(cms)),                 // per-cohort fragment rollout
});

Off means that the route does not exist for that request. Under an owned base, the 404/error page of the app answers. Without one, the router falls through to the rest of the app. Thus a gated-off ogygia route can be served by a plain Kit route at the same path, during a migration.

The gate is any (c) => boolean, which is exactly what a boolean flag() is. Thus a vendor-decided kill switch gates full routes (the gate runs after the source primes). The decision is sticky per visitor. And it carries across federation, like each flag read. $infer sees the entry exactly as if unwrapped.

Mounting

Export the router. Serve it at either seam.

import { sequence } from '@sveltejs/kit/hooks';
import { handle as ogygiaHandle } from 'ogygia/server';
import { router } from '$lib/router';

export const handle = sequence(ogygiaHandle(), router.handle);

router.handle dispatches a match, or falls through to the rest of the app (a good citizen for URLs that it does not own). Or own a subtree from a Kit catch-all:

import { router } from '$lib/router';
export const GET = (event) => router.fetch(event.request, event);
export const entries = router.entries;      // static routes + declared dynamic entries → Kit's crawl
export const prerender = 'auto';

router.fetch(request, event?) returns the Response, or null on no-match. Table options:

routes(table, {
  base: '/app',                 // mount prefix, stripped before matching
  slash: 'never',               // trailing-slash policy — 'ignore' | 'never' | 'always' (308s)
  error: ErrorPage,             // root error boundary
  miss: (c) => c.error(404),    // response for an unmatched path under `base`
  load: authGuard,              // a table-wide load before EVERY route — a guard with no chrome
  visitor: session,             // THE identity → c.visitor (see above)
  experiments: [csrExp],        // auto-carried into federation hops — see Experiments
});

The table-wide load is the root +layout.server.ts load of Kit, without a component. It runs before each route (pages and endpoints). It throws redirect/error to gate. Its data merges into each page. It is the place for auth, when there is no shared shell to hang it on.

Kit ↔ ogygia/router

SvelteKitogygia/router
src/routes/ treeone routes({ … }) table
+page.sveltefirst arg of page()
+page.server.ts (load, actions)second arg of page()
+layout.svelte + its loadlayout(name, Component, { load })
+error.svelte{ error: Component } on layout() / routes()
+layout@ / +page@ resetplacement (put the route outside the wrap)
(group) folderthe layout name in App['(name)']
+server.tsan { GET, POST, … } object
await parent()read the merged data; in a load, await sharedLoad(c)
page.datathe leaf page’s merged data
depends + invalidate / invalidateAllinvalidateAll() (re-runs the branch’s loads)
throw redirect() / error()same names, from ogygia/router
./$types (PageData, …)App['/path'] — no codegen
$app/state, $app/navigationwork unchanged on router pages

Two routers, one app

This page is the server router: the route table. The SPA router is separate: it intercepts link clicks, and navigates on the client. They compose, and each operates without the other.