Routing
File-based routing over src/app — pages, layouts, dynamic segments, and navigation.
Every src/app/**/page.tsx becomes a route automatically — no router config to
write. A Vite plugin scans src/app and emits a virtual route table that
<AppRouter> (mounted for you by the scaffold's entry) matches on every
navigation.
src/app/
├─ layout.tsx # wraps every route
├─ page.tsx # "/"
├─ about/
│ └─ page.tsx # "/about"
└─ blog/
└─ [slug]/
└─ page.tsx # "/blog/:slug"Layouts
layout.tsx wraps every route beneath it (nested layouts compose root → leaf).
Your root layout should wrap its children in <App> — a full-window frame
component from murasaki:
import type { ReactNode } from 'react'
import { App, useContextMenu } from 'murasaki'
export default function Layout({ children }: { children: ReactNode }) {
useContextMenu([{ label: 'Reload', shortcut: 'command,R', action: () => location.reload() }])
return <App className="flex items-center justify-center">{children}</App>
}Dynamic segments
A [param] folder captures a URL segment; read it with useParams():
import { useParams } from 'murasaki'
export default function BlogPost() {
const { slug } = useParams()
return <p>Post: {slug}</p>
}Static segments always win over dynamic ones, and a more-specific match (more
literal segments) wins over a less-specific one — the same precedence Next.js
uses. A (group) folder — parens, no brackets — organizes routes without
adding a URL segment.
Catch-all segments
A [...name] folder captures every remaining segment as a string[]; it
requires at least one segment to match. [[...name]] (double brackets) is the
optional form — it also matches the parent path itself, with the param left
undefined:
import { useParams } from 'murasaki'
export default function DocsPage() {
const { slug } = useParams()
// "/docs" -> slug is undefined
// "/docs/a/b" -> slug is ["a", "b"]
return <p>{slug ? slug.join('/') : 'index'}</p>
}Specificity follows Next.js: static > dynamic ([name]) > catch-all
([...name]) > optional catch-all ([[...name]]).
Loading, error, and not-found boundaries
Drop these files next to (or above) a page.tsx:
loading.tsx— shown while the page suspends (wrapped in a<Suspense>).error.tsx— an error boundary; exports a component receiving{ error, reset }. Catches render errors thrown under that route.not-found.tsx— rendered when no route matches; the nearest one in the URL's ancestor chain wins.
Each is scoped to its own subtree, the same as Next.js App Router.
Navigation
Use <Link> for client-side navigation (no full reload):
import { Link } from 'murasaki'
<Link href="/about">About</Link>useRouter() gives you push / replace / back / pathname imperatively,
and usePathname() reads the current path. A query string on push/replace/
<Link href> (e.g. /search?q=cats) is split from the pathname automatically
— route matching only ever looks at the pathname, and a query-only navigation
doesn't re-run middleware or change which page
renders.
useSearchParams() reads the current query string as a URLSearchParams,
reactive to every navigation:
import { useSearchParams } from 'murasaki'
function SearchResults() {
const params = useSearchParams()
return <p>Query: {params.get('q')}</p>
}A plain <a href="https://..."> to an off-origin URL opens in the user's
default system browser, not inside the app window — you don't need to
special-case external links.