Routing
File-based routing with dynamic params, catch-all routes, route groups, and layouts.
Bosia uses file-based routing. Files in src/routes/ map directly to URLs.
Static Routes
src/routes/+page.svelte → /
src/routes/about/+page.svelte → /about
src/routes/blog/+page.svelte → /blogEach +page.svelte file becomes a page at its directory's path.
Dynamic Routes
Wrap a directory name in brackets to create a dynamic segment:
src/routes/blog/[slug]/+page.svelte → /blog/hello-world
/blog/my-post
/blog/anythingAccess the matched value via params:
// +page.server.ts
export async function load({ params }: LoadEvent) {
const post = await getPost(params.slug);
return { post };
}Inside +page.svelte and +layout.svelte, params is a top-level prop alongside data (same shape as SvelteKit):
<script lang="ts">
import type { PageProps } from "./$types";
let { data, params }: PageProps = $props();
// params.slug — string typed from the route pattern
</script>Removed in 1.1.0:
page.params(frombosia/client) no longer exists. Readparamsfrom$props()as shown above.
Catch-All Routes
Use [...rest] to match multiple path segments:
src/routes/all/[...catchall]/+page.svelte → /all/a
/all/a/b/c
/all/anything/hereparams.catchall contains the full matched sub-path (e.g. "a/b/c").
A catch-all can follow a param: [lang]/[...rest] matches /en/a/b. It must be the last part of the route — docs/[...path]/edit fails the build.
A param name follows TypeScript variable rules, ASCII only: letters, digits, _ and $, not starting with a digit ([slug], [x123], [user_id], [_id], [...path]). Anything else, like [post-id] or [file.name], fails the build. Brackets must wrap a whole segment: [id].json, post-[slug], optional [[lang]] or [id?], and matchers like [id=integer] are not supported and fail the build.
Route Groups
Directories wrapped in parentheses are invisible in the URL but let you share layouts:
src/routes/(public)/+layout.svelte ← shared layout
src/routes/(public)/+page.svelte → /
src/routes/(public)/about/+page.svelte → /about
src/routes/(admin)/+layout.svelte ← different layout
src/routes/(admin)/dashboard/+page.svelte → /dashboardThe (public) and (admin) groups never appear in the URL. They only control which +layout.svelte wraps the pages inside.
Because groups vanish, two files can end up at the same URL — e.g. src/routes/+page.svelte and src/routes/(public)/+page.svelte are both /. The same goes for blog/[id] and (app)/blog/[slug]. Like SvelteKit and Next.js, Bosia refuses this: bun run build fails and bun run dev shows the error, naming both files. Delete or move one of them.
Route Priority
When multiple routes could match a URL, Bosia compares them segment by segment, left to right. At the first segment that differs, the more specific one wins:
- Static — a fixed name like
label - Dynamic —
[param] - Catch-all —
[...rest]
So /label/[id] wins over /[folder]/[thread] for /label/inbox, and /blog/[...slug] wins over /[lang]/about for /blog/about.
Layouts
+layout.svelte wraps all pages and child layouts in its directory:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import "../app.css";
let { children, data } = $props();
</script>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>
{@render children()}
</main>Layouts nest automatically — the root layout wraps group layouts, which wrap page layouts. Child content renders where {@render children()} appears.
Layout Data
Pair a layout with +layout.server.ts to load data:
// src/routes/+layout.server.ts
import type { LoadEvent } from "bosia";
export async function load({ locals }: LoadEvent) {
return {
appName: "My App",
user: locals.user,
};
}All child pages and layouts can access this data via parent() in their own loaders.
Error Pages
Create +error.svelte to handle errors thrown by loaders:
<!-- src/routes/+error.svelte -->
<script lang="ts">
import type { ErrorProps } from "./$types";
let { error }: ErrorProps = $props();
</script>
<h1>{error.status}</h1><p>{error.message}</p>The error page receives the HttpError thrown by error() in a loader. Place it at the route level where you want to catch errors — it catches errors from all child routes. ErrorProps and the underlying PageError type come from the generated ./$types module — no manual prop typing needed.
It also gets the failing route's params (let { error, params }: ErrorProps = $props()). An error page catches its child routes too, so a child's params (like params.slug under /blog) are typed string | undefined, and a 404 gets {}.
Nested error boundaries
+error.svelte can live in any route folder, not just the root. When a loader throws, Bosia walks up from the failing route and renders the nearest +error.svelte inside the matching prefix of the layout chain — so the surrounding nav, header, and other layouts above the boundary stay visible while only the broken page is replaced.
src/routes/
+layout.svelte ← root chrome
+error.svelte ← global fallback
blog/
+layout.svelte ← blog chrome
+error.svelte ← catches errors from /blog and /blog/*
[slug]/
+page.server.ts ← if this throws, the blog +error.svelte renders
wrapped in root layout + blog layoutRules:
- An error thrown by a
+pageor+page.server.tsis caught by the deepest+error.svelteat or above the page's depth. - An error thrown by a
+layout.server.tsis caught by the deepest+error.svelteabove the failing layout — the boundary in the same folder cannot catch its own layout (it would render inside the broken layout). - If no nested boundary matches, the root
+error.svelteis used. If there is none, a plain-text response is returned. - During in-app navigation the surrounding layout stays mounted — only the broken page is swapped out, no full reload.
Loading Skeletons
Create +loading.svelte in a route folder to show a skeleton while navigating to that page, instead of leaving the old page on screen until the new one's code and data finish loading:
<!-- src/routes/blog/+loading.svelte -->
<div class="skeleton">
<div class="line"></div>
<div class="line"></div>
</div>Like +error.svelte, it renders inside the layouts shared between the current page and the destination — so surrounding nav, header, and tabs stay mounted and only the content area shows the skeleton. Layouts the destination adds aren't mounted yet, so the +loading.svelte draws that chrome itself (same idea as Next.js loading.tsx).
Rules:
- Shown only on real URL changes — not on
invalidate()or same-path reruns. - No
+loading.svelte⇒ unchanged behavior (old page stays until the swap). - Cascades to child routes, nearest ancestor winning — one
+loading.svelteat the top of a section covers every page beneath it. Drop another one further down to give a subtree its own skeleton.
Page Options
Toggle rendering behavior per page by exporting flags from +page.server.ts:
// src/routes/dashboard/+page.server.ts
export const ssr = false; // skip server render, ship shell + hydrate on client
export const csr = false; // skip client hydration, server-rendered HTML only
export const prerender = true; // build to static HTML at `bosia build`
export const trailingSlash = "never"; // canonicalize URL form: "never" | "always" | "ignore"ssr = false— serverload()still runs and its result is injected as page data; the client hydrates and renders. Use for pages with browser-only deps (window, charts, third-party widgets) or auth-gated views where SSR adds latency without SEO value.csr = false— no JS shipped for the page. Static HTML only.prerender = true— captured at build time. For dynamic routes, also exportentries()returning the param values to prerender. Values use only ASCII letters, digits,-,_,.and~(plus/in a catch-all), like50-off; anything else fails the build.trailingSlash— canonicalize the URL form. Defaults to"never"."never"(default) —/about/→ 308 →/about. Static export emitsabout.html."always"—/about→ 308 →/about/. Static export emitsabout/index.html."ignore"— accept both forms with no redirect. Discouraged for SEO; useful when behind a CDN that already canonicalizes.- Set on
+layout.server.tsto cascade to all child pages; child page wins on conflict. - 308 (permanent) preserves the request method, so form
POSTs submitted to the wrong slash still reach the action. - Root
/is never modified. API routes (+server.ts) are unaffected.
ssr = false together with csr = false would render nothing and is overridden to csr = true (with a dev warning). ssr = false together with prerender = true is contradictory; the route is skipped during prerender. The same goes for a page that exports actions: a static file can't run a form action, so the page stays live.
bosia build and bosia dev read these flags by importing each +page.server.ts, +layout.server.ts and +server.ts, so any spelling works (export { p as prerender }, let, a shared constant). Top-level code in those files runs at build time: if one throws (say it connects to a database the build machine can't reach), bosia build fails (bosia dev warns and keeps the file's last flags; it retries on the next rebuild, or once the file is saved if it timed out). Move that work into load() or behind a function. In bosia dev the flags are re-read when a route server file is saved; after changing a constant imported from src/lib, save the route file (or restart dev; editing .env also re-reads them). These files run from a bundled copy, so import.meta.dir points at the build output, not src/routes (the build warns). In entries(), which runs only at build, read files next to a route with a path from process.cwd(), such as src/routes/blog/posts. A load() that runs live must not read src/: production images ship only dist/.