Concepts
Routing
Routes are folders under src/routes, named like the Next.js App Router: pages, layouts, endpoints and error pages, with typed params.
#Concept
src/
routes/
layout.stsx root layout; a nested layout.stsx per folder
page.stsx /
about/page.stsx /about
blog/page.stsx /blog
blog/[slug]/page.stsx /blog/:slug
blog/[slug]/Comments.stsx colocated module, not a route
blog/_lib/posts.ts private folder: nothing below it routes
docs/[[...path]]/page.stsx /docs and /docs/* (optional rest)
files/[...path]/page.stsx /files/* (rest: one or more segments)
[[lang]]/contact/page.stsx /contact and /:lang/contact (optional param)
(marketing)/pricing/page.stsx /pricing (a group folder adds no URL segment)
api/posts/route.ts endpoint: export GET / POST / … = (ctx) => Response
admin/middleware.ts runs for the pages, actions and endpoints below /admin
sitemap.xml/route.ts endpoint at /sitemap.xml
not-found.stsx error.stsx error pages, nearest one wins
document.stsx optional: owns <html>, <head> and <body>
middleware.ts optional: export default (ctx, next) => Promise<Response>- A folder is a URL segment; special files give it behavior:
page.stsxorpage.tsx(the page),route.ts(an endpoint),middleware.ts(folder middleware), andlayout,not-foundanderror(.stsxor.tsx). There is noindexand no flat page file:about/page.stsxis the page at/about, andabout.stsxis not a route. A folder has a page or aroute.ts, not both (LUM5003). - Colocation: any other file in
src/routes(components, helpers, styles, tests) is ignored by the router, so it can live next to the route that uses it. A folder whose name starts with_is private: nothing below it routes, not even apage.stsx. - Segments:
[param]matches one segment,[[param]]zero or one,[...rest]one or more and[[...rest]]zero or more (joined with/);(group)folders add no segment. A rest segment is the last one of the URL. An optional param that matched nothing is absent:params.langisundefinedat/contact. - Matching priority: segment by segment, static beats
[param], which beats[...rest]. A route with optional segments competes with each URL shape it serves:/[[lang]]/contactserves/contactas a static route. Two files that serve the same URL areLUM5003, including a[[...rest]]folder next to a page for the URL without it. - Next.js files Lumiose does not have are
LUM5002, so they never silently do nothing:loading,template,defaultandglobal-errorfiles,@slotfolders (parallel routes) and(.)folders (intercepting routes). - Layouts wrap every page below their folder and receive
{ params, children }.childrenis the page (the outlet). - Folder middleware: a
middleware.tsruns for every page, form action and endpoint below its folder (group folders included), aftersrc/middleware.tsand outermost folder first. It is the place for a section's auth, since a layout does not see the folder's actions and endpoints. See Server;src/routes/middleware.tsisLUM5002(usesrc/middleware.ts). - Pages export a
defaultcomponent (it may beasync), which receives{ params }. Other page exports:mode,ssr,prerender,staticPaths,action/actions,headersandlimits(see Rendering modes, Data and actions and Security). Endpoints may exportlimitstoo. - Error pages:
not-foundrenders unmatched URLs andnotFound();errorrenders thrown errors anderror(status). Both receive{ status, error }. Outside dev, unexpected errors are masked as 500. An error page renders inside the layouts of its folder, like a page there: the rootnot-foundgets the rootlayout(header, navigation), so it only renders its own content. If a layout itself fails, the error page renders without layouts. To give errors a different frame, move the regular pages into a group with their own layout ((app)/layout.stsx) and keep the error page outside it.
Locales. With lumiose({ i18n }), the router also serves each page under the prefixes of the
locales it declares (/es/about from about/page.stsx), and href(), <html lang> and the
hreflang links follow. See Internationalization.
Typed routes. lumiose typegen (run by lumiose check and the dev server) writes
.lumiose/types/routes.d.ts, which augments RouteMap (pages and endpoints) and LayoutMap
(the folders with a layout or a middleware file). Every route-bound type then takes the route pattern as a string literal, checked
against that table: an unknown or stale pattern is a type error, and editors complete it.
href("/blog/[slug]", { slug })builds a URL and type-checks the pattern and params; the URL includes Vite'sbaseand followstrailingSlash(reference);PageProps<"/blog/[slug]">types a page's props,LayoutProps<"/blog">a layout's ({ params, children }, with the params of the layout's URL scope; route groups such as(app)add nothing to it), andparams<"/blog/[slug]">()the params anywhere on the server;RequestContext<P>,ActionContext<P>andEndpoint<P>typectx.paramsof middleware helpers, actions and endpoints (see Data and actions), andMiddleware<"/admin">types a folder's middleware:ctx.paramswith the folder's params andctx.routewith the patterns below it;- optional params are optional properties (
RouteParams<"/[[lang]]/contact">is{ lang?: string }), andhref()leaves out an optional segment whose param is missing or empty:href("/docs/[[...path]]")is/docs.
Navigation. Routes are MPA by default: links load the next document. Two lumiose() options in
vite.config.ts make these navigations smooth (reference); both are
progressive, so browsers without support navigate normally:
- Prefetch (
prefetch, default"hover"). Pages get<script type="speculationrules">that prefetches same-origin links: when the pointer rests on a link ("hover"), early, as the browser sees fit ("viewport"), or never ("none"). Links to endpoints are never prefetched, since a GET endpoint may act (/logout).spapages get no rules: there<Link prefetch>decides per link. - Per link,
<Link prefetch>fromlumiose/routeroverrides the default on MPA pages too. There it renders a plain<a>(no JavaScript, no router) with adata-l-prefetchmarker, and the rules prefetch it at its own level:"none"opts the link out,"viewport"prefetches it early,"hover"on hover.<Link>withoutprefetch, like a plain<a>, follows the default. The server<Link>tells the page it rendered a marker as it renders; a hand-writtendata-l-prefetchattribute on a plain<a>does not change the page's rules. - View Transitions (
viewTransitions: true, off by default). Every page opts into cross-document View Transitions (@view-transition { navigation: auto }), except for readers who prefer reduced motion: the old page cross-fades into the new one. An element morphs into its counterpart on the next page when both have the same CSSview-transition-name, unique on each page: in a style block (.site-header { view-transition-name: site-header; }) or per item withstyle={{ "view-transition-name": `cover-${post.slug}` }}. Style the animation with the::view-transition-*pseudo-elements in a global style block.
// vite.config.ts
export default defineConfig({ plugins: [lumiose({ viewTransitions: true })] });import { Link } from "lumiose/router";
// In any page or component: the next page prefetches early, the archive never.
<Link href="/page/2" prefetch="viewport">Older →</Link>
<Link href="/archive" prefetch="none">Archive</Link>Routes with mode = "spa" use the client router lumiose/router (reference):
- The SPA starts at the outermost
spalayout (or the page itself) and covers everyspapage below it; links between them navigate on the client, other links load the document. <Link href prefetch>(plain<a>links work too),navigate(href, { replace }), reactiveparams(),route()(the matched pattern),url(),searchParams()(searchParams.set()changes the query) andisNavigating().- Layouts persist: a layout's
childrenis the outlet, and layouts shared by the old and the new page keep their state. A page with other param values (songs/[id]) is a new page. - A navigation holds the old page until the new page's async content is ready, then swaps. With
viewTransitions: true, a navigation to another page swaps insideviewTransition()(a query change on the same page does not), and<ViewTransition>names the elements that morph. - Scroll restoration, focus reset, an announcement of each new page for screen readers (its
<Title>, else its first<h1>),not-foundfor unknown URLs andnotFound(),errorfor errors. See Behaviour. - Navigation hooks:
beforeNavigate(fn)runs before each navigation and maycancel()it (to confirm leaving unsaved changes; the page stays, and for"leave", such as closing the tab, the browser asks), andafterNavigate(fn)runs after each completed one and once on mount (page views, focus). They belong to the component that calls them during setup, and work in islands of MPA pages too (link clicks,"leave"and"enter"). See Navigation hooks, and Unsaved changes for a guard with your own dialog.
import { signal } from "lumiose";
import { afterNavigate, beforeNavigate } from "lumiose/router";
export function Editor() {
const dirty = signal(false);
beforeNavigate((navigation) => {
if (!dirty()) return;
if (navigation.type === "leave") navigation.cancel(); // the browser asks
else if (!confirm("Discard your changes?")) navigation.cancel();
});
afterNavigate((navigation) => console.info("page view", navigation.to?.url.pathname));
return <textarea onInput={() => dirty.set(true)} />;
}Status: file routes, the manifest, matching, typegen,
href(), the request handler, MPA prefetch and View Transitions, and the SPA router (lumiose/router) are available, served bylumiose dev,lumiose buildandlumiose preview.
#API
| API | Use |
|---|---|
export default function Page(props: PageProps<"/x/[id]">) |
A page component. |
export default function Layout(props: LayoutProps<"/x">) |
A layout: { params, children }. |
params<"/x/[id]">() |
The matched route's params, typed by pattern (server). |
href(pattern, params?) |
Build a URL from a typed route pattern. |
Routes, RouteParams<P>, RouteMap, LayoutMap |
Route types. |
ErrorPageProps |
Props of not-found and error pages. |
notFound(), error(status, message?), redirect(to, status?) |
Stop rendering; see Server. |
lumiose/router: Link, navigate, isNavigating, searchParams, reactive url/params/route |
SPA routes; Link, the hooks and searchParams in islands too (reference). |
lumiose/router: beforeNavigate(fn), afterNavigate(fn) |
Run around navigations; cancel one (hooks). |
#Example
// src/routes/layout.stsx
import { href, type LayoutProps } from "lumiose";
export default function Layout(props: LayoutProps<"/">) {
return (
<div class="app">
<nav>
<a href={href("/")}>Home</a>
<a href={href("/blog/[slug]", { slug: "hello-world" })}>First post</a>
</nav>
{props.children}
</div>
);
}
<style global>
body { margin: 0; font-family: system-ui, sans-serif; }
</style>// src/routes/blog/[slug]/page.stsx
import { Title, notFound, type PageProps } from "lumiose";
const posts: Record<string, string> = { "hello-world": "Hello, world" };
export const mode = "server";
export default function Post({ params }: PageProps<"/blog/[slug]">) {
// A function, so the JSX re-reads it when the params change (a plain const would be LUM2001).
const title = () => posts[params.slug] ?? notFound();
return (
<article>
<Title>{title()}</Title>
<h1>{title()}</h1>
</article>
);
}// src/routes/not-found.tsx: renders inside routes/layout.stsx above (its nav and styles), so only
// the page content goes here
import type { ErrorPageProps } from "lumiose";
export default function NotFound({ status, error }: ErrorPageProps) {
return (
<main>
<h1>{status}</h1>
<p>{error.message}</p>
</main>
);
}// src/routes/page.tsx
export default function Home() {
return <h1>Home</h1>;
}#Common mistakes
- A flat or
indexpage file.src/routes/about.stsxandsrc/routes/blog/index.stsxare colocated modules, not routes: name themabout/page.stsxandblog/page.stsx.lumiose routeslists what the router sees. - A
[...rest]folder for its parent's URL.docs/[...path]/page.stsxdoes not match/docs: usedocs/[[...path]]/page.stsx, or adddocs/page.stsx. - Two files for one URL.
cart/page.stsxand(shop)/cart/page.stsxboth match/cart, and so dofeed/page.stsxandfeed/route.ts(LUM5003). - Hard-coded URLs.
"/blog/" + slugbreaks silently when a route moves;href("/blog/[slug]", { slug })fails type-checking instead. - Reading params from
window.locationin a server component. Useprops.paramsorparams(). - Matching
url().pathnameto know which route is showing.route() === "/songs/[id]"compares the matched pattern, type-checked against the app's routes. - Route loaders. There are none: load data in the async page component itself (decision-18).
- A custom dialog for
"leave". For closing the tab, a reload, a typed URL or aPOSTform (navigation.type === "leave"),cancel()makes the browser show its own prompt; aconfirm()or a modal cannot run there. Ask with your own UI for the rest: links (on MPA pages too) andnavigate()stay on the page when cancelled. modethat is not a string literal.export const mode = getMode()cannot be read at build time (LUM5002).
#Related diagnostics
- LUM5002: invalid route file.
- LUM5003: duplicate route.
- LUM5001: request API used during prerender.
- LUM5008: folder middleware covering prerendered routes.
See also: Rendering modes, Server, reference: app layer, lumiose/router.