Routing

Every file under app/pages/ is a route. The component samples in the guides use React; the other libraries use the same names, and Adapters lists what differs.

Files and routes

File in app/pages/PathNotes
index.tsx/
about.tsx/about
blog/index.tsx/blog
blog/[slug].tsx/blog/:slugrequired param
users/[[id]].tsx/users/:id?optional param
docs/[...path].tsx/docs/**:pathcatch-all, one or more segments
files/[[...path]].tsx/files/**:path?optional catch-all, zero or more
shop/[category]-[id].tsx/shop/:category-:idseveral params in one segment
(marketing)/pricing.tsx/pricinga (group) folder adds nothing to the path
[...slug].tsxevery unmatched paththe app's own 404 page, for a request that prefers HTML; it answers 200 unless the page throws createError({ status: 404 }). A request that does not prefer HTML and matches no page gets a JSON 404

Files and folders that start with _ or . are ignored, and so are *.test.*, *.spec.* and *.d.ts. Two files that resolve to the same path are a build error that names both.

A page reads its params with useRoute():

import { useRoute } from "@silgi/react";

export default function BlogPost() {
  const { params } = useRoute();
  return <h1>Post {params.slug}</h1>;
}

A catch-all param is a string[]. [...path] never matches an empty path; use [[...path]] for that.

import { useRoute } from "@silgi/react";

export default function DocsPage() {
  const { params } = useRoute();
  const segments = params.path as string[];
  return <p>{segments.join(" / ")}</p>;
}

The best match wins, compared segment by segment: a static segment, then a segment that mixes text and params, a required param, an optional param, a catch-all, and last an optional catch-all. A param is decoded with decodeURIComponent; a malformed escape answers 400. Matching is case-sensitive and ignores one trailing slash. Set app.trailingSlash to "redirect" and a page request for /about/ is answered with a 308 to /about, keeping the query. This holds in the built server and in silgi dev. A path that matches no page stays 404, and server routes are not redirected. The default is "ignore".

Nested routes

parent.tsx next to a folder parent/ makes the folder's routes its children. The parent renders Outlet where the child goes. Without a parent/index.tsx, /parent renders the parent with an empty Outlet.

import { Outlet } from "@silgi/react";

export default function Account() {
  return (
    <section>
      <h1>Account</h1>
      <Outlet />
    </section>
  );
}

The load files of every matched route start together; a load that awaits parent() waits for its parent first, which costs a round trip when the parent's data comes from a handler (Data).

Root component

app/app.<ext> is an optional root component. It wraps every layout and page and renders Outlet where they go. The extension follows the library: app.tsx for React, Preact and Solid, app.vue, app.svelte, app.ts for html. Only the app's own app/ folder counts; a layer in extends cannot provide one.

import { Link, Outlet } from "@silgi/react";
import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button type="button" onClick={() => setCount(count + 1)}>Clicked {count}</button>
      <nav>
        <Link to="/">Home</Link> <Link to="/about">About</Link>
      </nav>
      <Outlet />
    </div>
  );
}

In Vue, Svelte and html the root renders Outlet too:

<script setup lang="ts">
import { ref } from "vue";
import { Link, Outlet } from "@silgi/vue";

const count = ref(0);
</script>

<template>
  <div>
    <button type="button" @click="count++">Clicked {{ count }}</button>
    <nav>
      <Link to="/">Home</Link> <Link to="/about">About</Link>
    </nav>
    <Outlet />
  </div>
</template>
<script lang="ts">
  import { Link, Outlet } from "@silgi/svelte";

  let count = $state<number>(0);
</script>

<div>
  <button type="button" onclick={() => count++}>Clicked {count}</button>
  <nav>
    <Link to="/">Home</Link> <Link to="/about">About</Link>
  </nav>
  <Outlet />
</div>
import { Link, Outlet } from "@silgi/html";

export default function App(): string {
  const nav = `<nav>${Link({ to: "/", children: "Home" })} ${Link({ to: "/about", children: "About" })}</nav>`;
  return `<div>${nav}${Outlet()}</div>`;
}
  • The root stays mounted when you navigate in the browser, so its state survives. Changing the layout swaps what is below it.
  • A page with meta.layout: false is still wrapped by the root.
  • useData() in the root is null, as in a layout: only a route's own load gives it data. useRoute() is the current page's route.
  • useHead in the root is applied first, so a layout or a page overrides its title and tags.
  • The error page renders without the root, so a broken root cannot hide an error.

Layouts

app/layouts/default.tsx wraps every page that does not name another layout. A layout receives the page as children. Layouts are files directly in app/layouts/, named by their file name.

import type { ReactNode } from "react";

export default function AdminLayout({ children }: { children?: ReactNode }) {
  return (
    <div>
      <nav>Admin</nav>
      {children}
    </div>
  );
}

Moving between two pages with the same layout keeps the layout's state; changing the layout unmounts the old one. An unknown layout name is a build warning and a 500 error page that names it.

Page meta

A page exports meta to choose its layout and middleware. Silgi reads it at run time when the page module loads, so keep it a plain object. Write it with satisfies PageMeta (from silgi/app) so TypeScript checks it.

import type { PageMeta } from "silgi/app";

export const meta = {
  layout: "admin",
  middleware: ["auth"],
} satisfies PageMeta;

export default function AdminHome() {
  return <h1>Admin</h1>;
}
KeyMeaning
layouta layout name, or false for none
middlewarea route middleware name ("auth"), an inline middleware function, or an array of them; any other value is a 500 error that names the page
ssrfalse makes this page a single-page route: the server sends the shell and the browser renders it
scriptshtml only: false makes this page script-free, a document with no script and no import map, so every link on it is a full page load; other libraries ignore it with a build warning

When a parent and a child both export meta, the child's key replaces the parent's; arrays are not joined. In Vue the meta export goes in a plain <script lang="ts"> block next to <script setup>. In Svelte it goes in <script module>.

Route middleware

Route middleware runs before a page renders: on the server for the first request, in the browser for every later navigation. It steers the visitor and guards nothing: a user can skip it in the browser, and a navigation reaches the data through handlers, not through it. A check that must hold belongs in the handler (Sessions and security). It has four outcomes:

  • return nothing to continue;
  • return redirect(to, status = 302) to redirect;
  • return false, or abortNavigation() from silgi/app, to cancel the navigation: no later middleware runs, and in the browser the page and the URL stay as they are. On the server, and on the first load of a single-page route, there is no page to stay on, so the answer is a 404 Page Not Found: <path>. abortNavigation(error) with an argument throws createError(error) instead;
  • throw createError to show the error page.
import { defineRouteMiddleware, redirect } from "silgi/app";

export default defineRouteMiddleware((to, from, ctx) => {
  if (!ctx.state.get("user")) return redirect(`/login?next=${encodeURIComponent(to.fullPath)}`);
});

Nothing sets user by itself. The Forms and login guide sets it in an app plugin that asks a handler.

import { abortNavigation, defineRouteMiddleware } from "silgi/app";

export default defineRouteMiddleware((to, from, ctx) => {
  if (to.path.startsWith("/checkout") && ctx.state.get("cart") === undefined) return abortNavigation();
});

A redirect to another origin needs { external: true }. Without it the target is refused: the server answers a 500 error page and sends no Location, and the browser shows the error page. A target that starts with // or \\ counts as another origin. Only a scheme in app.linkProtocols is accepted, with or without external; see Link protocols.

import { defineRouteMiddleware, redirect } from "silgi/app";

export default defineRouteMiddleware((to) => {
  if (to.path === "/billing") return redirect("https://billing.example.com/", { external: true });
});

Files in app/middleware/ are named by their file name without a numeric order prefix: auth.ts is used as middleware: "auth" or middleware: ["auth"], and so is 01.auth.global.ts, which is auth and global. The run order still follows the full file name, so 01.b.global.ts runs before 02.a.global.ts. A name with an upper-case letter also answers to its kebab-case form: myAuth.ts is found as "myAuth" and as "my-auth". Two files that give the same name, such as 01.a.ts and a.ts, fail the scan, and so does an alias that equals another file's name. The old prefixed name ("01.auth") still works through 1.x and logs middleware "01.auth" is deprecated; use "auth" once in silgi dev. A file named *.global.ts runs on every navigation first, in file name order, and cannot be named in meta. A .server.ts or .client.ts ending limits a file to one side. The function receives the target route, the route it came from (undefined on the server and on the first browser load), and { state, app, event }: state is the same store as State, app is the app context, and event is the request's SilgiEvent on the server and undefined in the browser.

A redirect to the URL being answered is a 500 error that names the loop. While silgi generate prerenders, a page whose middleware redirects is skipped with a warning, since the build has no session; give the route ssr: false to prerender its shell and run the middleware in the browser.

Links to your own pages are handled by the browser navigator: it fetches the page chunk and runs the page's load files, runs middleware, renders, updates the head and restores or resets the scroll position. Use Link and useRouter.

import { Link, useRouter } from "@silgi/react";

export default function Menu() {
  const router = useRouter();
  return (
    <nav>
      <Link to="/blog" prefetchOn="visibility">Blog</Link>
      <Link to="/pricing" prefetch={false}>Pricing</Link>
      <button type="button" onClick={() => router.navigate("/login", { replace: true })}>
        Sign in
      </button>
    </nav>
  );
}

Link prefetches the page chunk and the load data on hover and focus (prefetchOn="interaction", the default). prefetchOn="visibility" loads the chunks only, when the link scrolls into view, and prefetchOn={{ visibility: true, interaction: true }} does both; the data is fetched on hover, on focus and by router.prefetch(to). At most 4 visible links are prefetched at a time, when the browser is idle. The browser's data-saver mode and a 2g connection turn the automatic prefetch off; router.prefetch(to) always runs. In silgi dev visible prefetch is off and hover and focus load chunks only, unless devServer.prefetch is true. prefetch={false} or noPrefetch turns it off for one link. A prefetched answer lives 30 seconds and is used once. Add replace to replace the history entry.

The to of Link and of the router is an app path: Silgi adds app.baseURL in front of a path that starts with /, and leaves a relative target or a URL with a scheme as it is. To build such an href yourself, use withBase.

The router has navigate(to, { replace, scroll, external, document, shallow, state, refreshAll, refresh }), replace(to, { scroll, external, state }), back, forward and prefetch; The router lists the rest. prefetch(to, { data }) resolves to what it did: { type: "loaded", data }, { type: "redirect", location }, { type: "error", error }, { type: "chunks" } or { type: "skipped" }; { data: false } loads the chunks only. A target on another origin makes navigate and replace reject with a TypeError unless you pass external: true, which loads it as a full page load. clearError({ redirect }) takes no external and rejects such a target.

import { useRouter } from "@silgi/react";

export default function Billing() {
  const router = useRouter();
  return (
    <button type="button" onClick={() => router.navigate("https://billing.example.com/", { external: true })}>
      Billing
    </button>
  );
}

With shallow: true a navigation to the page on screen changes only the URL: useRoute().query, hash and fullPath are updated, and no middleware, load, request, scroll change or page hook runs. Use it for state kept in the query, such as a table's page number. A shallow target that is another route runs a full navigation. A direct history.pushState or history.replaceState to a URL of the same route has the same effect, and back and forward between two entries of one route that differ in query or hash update the route without a request.

import { useRoute, useRouter } from "@silgi/react";

export default function Table() {
  const router = useRouter();
  const route = useRoute();
  const page = Number(route.query.page ?? 1);
  return (
    <button type="button" onClick={() => router.navigate(`?page=${page + 1}`, { shallow: true })}>
      Page {page + 1}
    </button>
  );
}

Search params

A page may export search, a Standard Schema (the interface Zod, Valibot and ArkType implement) for its query string. Silgi validates the query before route middleware, on the server and in the browser, and every reader gets the result with defaults filled in: ctx.search in a load, to.search in middleware, and useRoute().search in a component. A page without search has search equal to query (with app/search.ts, what its parse returns). In a Vue page the export goes in the plain <script lang="ts"> block, in Svelte in <script module>.

import { Link, useSearch } from "@silgi/react";
import type { PageMeta } from "silgi/app";
import { schema } from "../utils/standard-schema.ts";

export const search = schema<{ page: number; tab?: string }>(
  { type: "object", properties: { page: { type: "integer" }, tab: { type: "string" } } },
  (value) => {
    const query = (value ?? {}) as { page?: unknown; tab?: unknown };
    const page = query.page === undefined ? 1 : Number(query.page);
    if (!Number.isInteger(page) || page < 1) return { issues: [{ message: "page must be a whole number from 1", path: ["page"] }] };
    return { value: { page, ...(typeof query.tab === "string" ? { tab: query.tab } : {}) } };
  },
);

export const searchRetain = ["tab"];

export const meta = { searchError: "strip" } satisfies PageMeta;

export default function Products() {
  const page = useSearch((current) => Number(current.page));
  return (
    <nav>
      <button type="button" onClick={() => page.set((prev) => ({ ...prev, page: Number(prev.page) + 1 }))}>
        Page {page.value + 1}
      </button>
      <Link to="/products" query={(prev) => ({ ...prev, page: 1 })}>First page</Link>
    </nav>
  );
}
  • When the query does not validate, the page answers 400 Invalid search params, with data.issues as a list of { path, message }. meta.searchError: "strip" drops the top-level keys the issues name and validates again; it fails as "error" when that does not help. The innermost page's value wins.
  • Parent routes validate first: each schema in the chain gets the query and its output is merged over the earlier ones.
  • After silgi typegen, the output type of the schema is the type of search for that route.
  • useSearch(select?) returns { value, set }. set(next | (prev) => next, { replace = true }) navigates to the page on screen with that search. It runs the guards and the load files whose searchKeys changed, and replace: false adds a history entry. A selected value is compared by deep equality, so a component updates only when its part changes. router.navigate({ query: (prev) => next }) does the same without the hook, and Link takes query={(prev) => next}.
  • A key is left out of a URL written this way when validating the search without it gives the same value, so set({ page: 1 }) with a default of 1 writes /products. A query string you write by hand is never changed.
  • The default codec reads and writes flat URLSearchParams. A key that the schema's input JSON Schema types as an object, array, number or boolean is written as JSON (tags=["a","b"]) and parsed back. app/search.ts replaces the codec for the whole app, on the server and in the browser. A parse that throws makes the search invalid, with the same 400.
  • export const searchRetain = ["tab"] in a page carries those keys, when the page on screen has them, to every Link and navigate() built from that page or its children, unless the target sets them.
import { defineSearchCodec } from "silgi/app";

export default defineSearchCodec({
  parse: (search) => {
    const raw = new URLSearchParams(search).get("q");
    return raw ? (JSON.parse(atob(raw)) as Record<string, unknown>) : {};
  },
  stringify: (value) => (Object.keys(value).length ? `q=${btoa(JSON.stringify(value))}` : ""),
});

The router

useRouter() has more than navigate. These work in the browser; on the server resolve, getRoutes, hasRoute and currentRoute work and the rest do nothing or throw only available in the browser.

CallDoes
push(to, options)the same as navigate
go(delta), back(), forward(), canGoBack()history; canGoBack() is false on the first entry of the session
resolve(to){ href, path, name?, params, query, hash } of an app path or a { name, params } location, without navigating
beforeEach(guard)runs guard(to, from) on every client navigation after route middleware. false cancels, a path or a location redirects, a throw shows the error page
afterEach(hook)runs hook(to, from, failure?) after the commit, or with failure.type "cancelled" or "superseded"
onError(handler)gets every error a navigation shows or reloads for, before the error page
isReady()resolves once the first page has hydrated or mounted
getRoutes(), hasRoute(name), currentRoutethe page routes as { name, path }, a check for one, and the route on screen. There is no addRoute

beforeEach, afterEach and onError return a function that removes them.

import { onAfterNavigate, onBeforeNavigate, useRoute, useRouter } from "@silgi/react";
import { useEffect } from "react";

export default function Shell() {
  const router = useRouter();
  const route = useRoute();

  useEffect(() => {
    const removeGuard = router.beforeEach((to) => (to.path.startsWith("/admin") && route.meta.public !== true ? { name: "login" } : undefined));
    const removeAfter = router.afterEach((to, from, failure) => {
      if (!failure) console.log(`${from.path} to ${to.path}`);
    });
    const removeError = router.onError((error, to) => console.error(`navigation to ${to?.path} failed`, error));
    return () => {
      removeGuard();
      removeAfter();
      removeError();
    };
  }, [router, route.meta.public]);

  onBeforeNavigate((navigation) => {
    if (navigation.type === "leave" && document.hidden) navigation.cancel();
  });
  onAfterNavigate((navigation) => console.log(navigation.type, navigation.to?.path));

  const target = router.resolve({ name: "blog-slug", params: { slug: "hello" } });
  return (
    <a href={target.href} onClick={(event) => (event.preventDefault(), void router.push(target.path, { refreshAll: true }))}>
      {target.path}
    </a>
  );
}

navigate also takes refreshAll: true, which runs every load again and refreshes every mounted useAsyncData, past the prefetch and the client data cache, and refresh: ["posts:list"], which treats those keys as changed for this navigation. document: true loads the target as a document. To keep the scroll position of a navigation, call disableScrollHandling() in onNavigate, onAfterNavigate or the setup of the page you navigate to; called at another time it does nothing.

onBeforeNavigate(fn), onNavigate(fn) and onAfterNavigate(fn) run a function on each client navigation while the component is mounted. The navigation they get has type ("enter", "link", "goto", "popstate", "form" or "leave"), from, to (null when there is no page on that side) and cancel(). onBeforeNavigate may call cancel(); for leave, when the document unloads, the browser asks the user to stay. The order of one navigation, its page hooks included, is in the API reference. A page module may export onEnter(route), onStay(route) and onLeave(route), which run in the browser after a navigation committed; see silgi/app.

useRoute() also tells how the page came to be: status (the status of the document or of the error page), renderMode ("ssr", "spa" or "prerendered"), and navigationType.

redirect(to, { document: true }) returned from a load or middleware loads the target as a document, so cookies set on the answer are kept and the server renders the target; { replace: true } replaces the history entry instead of adding one. prefetch(to, { data }) in the router resolves to what it loaded, described under Navigation.

Leaving a page

Three tools guard a page that has unsaved work, from the weakest to the strongest:

  • onBeforeRouteLeave(fn) and onBeforeRouteUpdate(fn) run while a component is mounted. fn(to, from) answers as a beforeEach guard does. The first runs when the navigation leaves the page the component belongs to, the second when that page stays with new params or a new query. They run before route middleware. A guard in a layout or the root never runs.
  • useBlocker(shouldBlock) returns { state, location, proceed, reset }. When shouldBlock({ from, to, type }) is true, the navigation waits in the state "blocked" until your dialog calls proceed() or reset(). With beforeUnload (on by default), closing or reloading the tab shows the browser's own dialog while it blocks. usePreventNavigate(fn) is the shorter form that answers with a boolean.
  • onBeforeNavigate(fn) can call navigation.cancel().
import { onBeforeRouteLeave, useBlocker, useRouter, useSnapshot } from "@silgi/react";
import { useRef, useState } from "react";

export default function Editor() {
  const [dirty, setDirty] = useState(false);
  const note = useRef<HTMLTextAreaElement>(null);
  const router = useRouter();
  const blocker = useBlocker(({ to }) => dirty && to !== null);

  onBeforeRouteLeave((to) => (to.path === "/login" ? true : !dirty));
  useSnapshot("note", {
    capture: () => note.current?.value ?? "",
    restore: (value) => void (note.current && (note.current.value = value)),
  });

  return (
    <form>
      <textarea ref={note} onChange={() => setDirty(true)} />
      <button type="button" onClick={() => router.pushState("", { step: 2 })}>Next step</button>
      {blocker.state === "blocked" && (
        <dialog open>
          Leave without saving? <button type="button" onClick={blocker.proceed}>Leave</button> <button type="button" onClick={blocker.reset}>Stay</button>
        </dialog>
      )}
    </form>
  );
}

Every guard must let a navigation go; the first that blocks ends the list. A back or forward that is cancelled is undone with history.go, so the URL stays. A form's own navigation is never blocked, and a hash-only navigation runs none of these. Nothing here guards data: it steers the visitor, and the check that matters is in the handler.

History state and snapshots

navigate(to, { state }), replace(to, { state }) and <Link state={…}> store a serializable object with the new history entry. useRoute().state reads it ({} when none), and it survives a reload. At most 65,536 characters serialized; a larger or unserializable state rejects with a TypeError before any guard runs. Treat it as input: a user can edit history entries.

router.pushState(url, state) and router.replaceState(url, state) add or change an entry without a guard, middleware, load or request, whatever page url matches; "" keeps the URL. The page on screen stays, with useRoute().state set. Back from it restores the earlier state, and a reload of it loads its URL as a document. Use it for a modal or a step that has its own history entry.

import { useRoute, useRouter } from "@silgi/react";

export default function Wizard() {
  const route = useRoute();
  const router = useRouter();
  const step = Number(route.state.step ?? 1);
  return (
    <div>
      <p>Step {step}</p>
      <button type="button" onClick={() => router.pushState("", { step: step + 1 })}>Next</button>
      <button type="button" onClick={() => router.replaceState("", { step })}>Keep</button>
      <button type="button" onClick={() => router.navigate("/wizard/done", { state: { from: step } })}>Finish</button>
    </div>
  );
}

useSnapshot(key, { capture, restore }) keeps a value of the page per history entry, such as text typed into a form. When the page leaves an entry, capture() is stored; when back, forward or a reload brings the entry back, restore(value) runs. The value must be serializable and at most 65,536 characters. It is kept in memory and in sessionStorage (item silgi:snapshot, the last 100 entries), so capture() must never return a secret. A cookie change drops every stored value. The sample above saves a textarea this way.

A Link, navigate(), replace(), navigateTo(), redirect() and sendRedirect target may carry only a scheme listed in app.linkProtocols: http, https, mailto, tel and sms by default. The scheme is read as the browser reads it, so " JaVa\tScript:x" is javascript. Other targets are refused:

  • a Link renders href="#", and silgi dev logs Link target "javascript:…" blocked by app.linkProtocols once per scheme;
  • navigate(), navigateTo(), redirect() and sendRedirect throw a SilgiError with status 400 and the same message, and a load that returns such a redirect renders the error page with 400.

A listed scheme other than http and https, such as mailto:, is an external target: it needs { external: true } in navigate(). Add a scheme to app.linkProtocols to allow it (Configuration).

navigateTo(to, { replace, external, redirectCode }) of silgi/app (every adapter re-exports it) works outside components too: in the browser it is navigate, or replace with replace: true; during server rendering and in route middleware it throws the redirect redirect(to, { status: redirectCode }) returns, with status 302 by default.

A link is left to the browser when:

  • it has a target other than _self, a download attribute, rel="external" or data-silgi-reload, or is clicked with a modifier key;
  • it points to another origin or to a path outside app.baseURL;
  • its path is a server route that answers GET and no page has the same path, a file in public/, or a path with a proxy route rule;
  • its path ends in a file extension, as in /feed.xml, and no page claims that extension.

A navigation to a path with a redirect route rule is a full page load, so the server decides. A navigation that matches no route, or whose page chunk fails to load, becomes a full page load.