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
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), 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). - 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
(layouts). 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);- 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,
not-foundfor unknown URLs andnotFound(),errorfor errors.
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 and islands (reference). |
#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).
modethat is not a string literal.export const mode = getMode()cannot be read at build time (LUM5002).
#Related diagnostics
See also: Rendering modes, Server, reference: app layer, lumiose/router.