Bosia Bosia v1.2.9

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     →  /blog

Each +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/anything

Access 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 (from bosia/client) no longer exists. Read params from $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/here

params.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   →  /dashboard

The (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:

  1. Static — a fixed name like label
  2. Dynamic — [param]
  3. 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 layout

Rules:

  • An error thrown by a +page or +page.server.ts is caught by the deepest +error.svelte at or above the page's depth.
  • An error thrown by a +layout.server.ts is caught by the deepest +error.svelte above 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.svelte is 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.svelte at 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 — server load() 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 export entries() returning the param values to prerender. Values use only ASCII letters, digits, -, _, . and ~ (plus / in a catch-all), like 50-off; anything else fails the build.
  • trailingSlash — canonicalize the URL form. Defaults to "never".
    • "never" (default) — /about/ → 308 → /about. Static export emits about.html.
    • "always" — /about → 308 → /about/. Static export emits about/index.html.
    • "ignore" — accept both forms with no redirect. Discouraged for SEO; useful when behind a CDN that already canonicalizes.
    • Set on +layout.server.ts to 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/.