Coming from
Coming from Next.js
The Next.js App Router mapped to Lumiose: the same route file names, server components by default, client:* islands instead of "use client", form actions instead of server actions, head components instead of metadata, and rendering modes instead of caching rules.
This page maps the Next.js App Router (Next.js 16) to Lumiose's current API. For components and hooks, read Coming from React first; this page covers the framework.
#Mental model
The files look the same. Lumiose's routes follow the App Router names: a folder under
src/routes is a URL segment, and page, layout, route, error and not-found files give it
behavior, with [slug], [...slug], [[...slug]], (group) and _private folders
(Routing). The Next.js files Lumiose does not have (loading,
template, default, global-error, @slot and (.) folders) are errors
(LUM5002), so they never silently do nothing.
Components are server components by default. On static and server routes every component
renders on the server, may be async, and ships no JavaScript. Instead of a "use client" file
boundary, the page that uses a component decides: <LikeButton client:visible /> makes that one
instance an island that hydrates in the browser, and the same component
stays static elsewhere. Children passed to an island render on the server and stay there, as
server components passed to a client component do. An spa route hydrates its whole subtree and
navigates on the client.
The rendering mode is explicit, per route. Next.js decides between static and dynamic rendering
from what a page reads, and caching rules decide how long results live. In Lumiose a route says
export const mode = "static" | "server" | "spa" (or inherits it from a layout or the project).
static is the default: built once, served as files. A static page that reads the request fails
the build (LUM5001) instead of turning dynamic
(Rendering modes).
No RPC. Server actions are functions the client calls. Lumiose has form actions: a page exports
actions, a <form method="post" action="?/name"> posts to one and works without JavaScript, and
submit() posts it from an island. Other calls from the browser go to endpoints (route.ts)
(Data and actions).
Caching is per request and per page. fetchJSON() and query() dedupe the same URL or key
within a request on the server, and cache it for the page in the browser (stale-while-revalidate).
There is no data cache shared across requests and no incremental regeneration: for fresh-but-cheap
pages, render on the server and send cache-control headers to a CDN.
#From Next.js to Lumiose
#Routes and files
| Next.js | Lumiose |
|---|---|
app/ |
src/routes/. |
page.tsx, layout.tsx, route.ts, not-found.tsx, error.tsx |
The same names, as .stsx (with styles) or .tsx; route.ts for endpoints. |
[slug], [...slug], [[...slug]], (group), _folder |
The same, plus [[slug]] for one optional segment. |
PageProps<"/blog/[slug]">, await params |
PageProps<"/blog/[slug]"> and LayoutProps<"/blog"> from lumiose; params is a plain object. |
searchParams page prop |
url().searchParams on server routes; searchParams() from lumiose/router in islands and spa routes. |
Root layout.tsx with <html> and <body> |
src/document.stsx owns <html>, <head> and <body> (optional); src/routes/layout.stsx wraps the pages. |
loading.tsx |
<Suspense fallback> in the page or layout: on server routes, the content inside streams after the shell. |
error.tsx |
error.stsx, a page rendered with { status, error } inside its folder's layouts. For a retry in place, <ErrorBoundary fallback={(error, reset) => …}>. |
not-found.tsx, notFound() |
not-found.stsx (the nearest wins), notFound(). |
global-error.tsx |
The root error.stsx: when a layout itself fails, it renders without layouts. |
generateStaticParams() |
staticPaths(), returning [{ params: { slug } }]. |
Typed routes, `/blog/${slug}` in <Link href> |
href("/blog/[slug]", { slug }), type-checked against the routes. |
#Components and data
| Next.js | Lumiose |
|---|---|
Server components, async components |
Every component on static and server routes. Async components also work in islands and spa routes, in the browser. |
"use client" |
A client:* directive at the usage site: client:load, client:idle, client:visible, client:media; or an spa route. |
dynamic(() => import(…), { ssr: false }) |
client:only: rendered in the browser only. |
| Serializable props of client components | The same rule for island props, which also accept Date, Map, Set, URL, BigInt and FormData (LUM4002). |
import "server-only" |
*.server.ts modules or src/server/: importing them from browser code is LUM4003. |
fetch() in a server component, React.cache() |
await fetchJSON(url) or await query(key, fn) in the component: one request per URL or key per request; same-origin URLs are served in-process. |
"use cache", cacheLife, cacheTag, unstable_cache |
No cross-request data cache: the request cache above, cache-control headers for a CDN, or a cache of your own in a server module. |
revalidateTag(tag), updateTag(tag) |
In the browser, invalidate(["tag"]) refetches the query() entries whose key starts with it. |
revalidatePath(path), router.refresh() |
invalidate(): re-runs the page's async components on spa routes, reloads static and server pages. |
cookies(), headers() (async) |
cookies() and request().headers (synchronous), on server routes and in middleware, endpoints and actions. |
after(fn) |
ctx.platform.waitUntil(promise). |
process.env.SECRET, NEXT_PUBLIC_* |
env("SECRET") in server code; import.meta.env.PUBLIC_* in browser code. |
#Mutations
| Next.js | Lumiose |
|---|---|
"use server" and <form action={createPost}> |
export const actions = { create(ctx) {…} } and <form method="post" action="?/create">, which works without JavaScript. |
| A server action called from a client component | submit(target, { action: "/posts?/create" }) from an island (a form, a submit event, FormData or a record), or an endpoint called with fetchJSON(). |
useActionState(action, initial) |
actionData<typeof actions>(), or the result that await submit(event) resolves to. |
useFormStatus() |
isSubmitting(), or isSubmitting("create") for one action. |
useOptimistic() |
optimistic(source) with .update(fn, submit(event)). |
redirect() in a server action |
redirect(to) in the action: 303 after a POST. |
| Returning validation errors from an action | return fail(400, { errors }): the page re-renders with that status, and actionData() holds the errors. |
#Server and head
| Next.js | Lumiose |
|---|---|
Route handlers: GET(request, { params }), NextResponse.json() |
export const GET: Endpoint<"/api/posts/[id]"> = (ctx) => Response, with ctx.request, ctx.params, ctx.url, ctx.cookies; Response.json(). |
middleware.ts (proxy.ts in Next.js 16) |
src/middleware.ts: (ctx, next) => Response. NextResponse.next() is next(); request data goes on ctx.locals. |
config.matcher |
A middleware.ts in the route folder it covers (src/routes/dashboard/middleware.ts for /dashboard/:path*); it runs after src/middleware.ts. ctx.route, the matched pattern, serves other checks. |
instrumentation-client.ts |
src/instrumentation.client.ts: runs once per page load, before islands hydrate and the SPA starts, top-level await included; pages without JavaScript load only it (Islands). |
onRouterTransitionStart(url, type) in instrumentation-client.ts, a client error reporter |
Its instrumentations export wraps each navigation (navigate: start and end, with the route), submit, fetch and island hydrate; its onError(error, info) receives island, router and submit() errors (Islands). |
instrumentation.ts: register(), onRequestError() |
src/instrumentation.server.ts with the same exports: register() runs once per server instance, onRequestError(error, info) gets { request, route, params, phase, digest }; instrumentations time each phase (Server). |
metadata, generateMetadata() |
<Title>, <Meta> and <Head> rendered by the page or layout; an async page awaits its data first. The deepest wins (Head and document). |
generateViewport() |
<Meta name="viewport" … />, or the tag in document.stsx. |
sitemap.ts, robots.ts |
Endpoints: sitemap.xml/route.ts, robots.txt/route.ts, written at build time on static routes. |
next/script |
<Head><script src="…" async /></Head>; under a strict CSP, see Security. |
next/font |
@font-face in a <style global> block, with the font files in public/. |
next/image |
No image component: import photo from "./photo.jpg" gives the fingerprinted URL; write <img> with width, height, loading="lazy" and srcset. Vite plugins that resize images at build time work in vite.config.ts. |
next.config: headers(), basePath, trailingSlash, i18n |
lumiose({ headers, trailingSlash, i18n }) and Vite's base, in vite.config.ts. Redirects go in middleware. |
#Navigation
| Next.js | Lumiose |
|---|---|
next/link |
<a href={href(…)}>, or <Link href prefetch> from lumiose/router. MPA pages prefetch on hover by default; prefetch="viewport" matches Next.js. |
| Client-side navigation between pages | On spa routes, with persistent layouts. static and server pages load the next document, with optional View Transitions (lumiose({ viewTransitions: true })). |
useRouter().push(href), .replace(href) |
navigate(href), navigate(href, { replace: true }) from lumiose/router. |
usePathname(), useSearchParams(), useParams() |
url().pathname, searchParams() (and searchParams.set()), params() from lumiose/router. |
useSelectedLayoutSegment() |
route(), the matched pattern: route() === "/blog/[slug]". |
onRouterTransitionStart(url, type), a useEffect on usePathname() for page views |
afterNavigate(fn) from lumiose/router: after each completed navigation, and once on mount with type: "enter" (hooks). |
A beforeunload listener to protect unsaved changes |
beforeNavigate((navigation) => navigation.cancel()): it also stops link clicks (on MPA pages too), navigate() and back/forward, and the page stays; for "leave" (closing the tab, a reload), the browser asks. |
redirect(), permanentRedirect(), forbidden(), unauthorized() |
redirect(to), redirect(to, 308), error(403), error(401). |
#Rendering and deployment
| Next.js | Lumiose |
|---|---|
Static rendering, dynamic = "force-static" |
mode = "static", the default. |
Dynamic rendering, dynamic = "force-dynamic" |
export const mode = "server". |
| Partial prerendering, Cache Components | server + <Suspense>, or static + islands that call an endpoint (Close to partial prerendering). |
output: "export" |
Every route prerendered: the build writes no dist/server, and any static host serves dist/client (Static hosts). |
Custom server: next({ dev }) with getRequestHandler() |
createDevHandler({ server }) from @lumiose/vite/dev in dev and createHandler() from @lumiose/adapter-node in production, in one server.ts (@lumiose/vite). |
output: "standalone", next start |
dist/server/entry.js, served by @lumiose/adapter-node (createServer()) or lumiose preview. |
| Vercel, Netlify, Cloudflare (OpenNext) | lumiose({ adapter: vercel() }), netlify() or cloudflare() (Deploy). |
#What has no equivalent
| Next.js | Instead |
|---|---|
Parallel routes (@slot, default.tsx) |
Render the parts as components of one layout or page; branch on route() or a search param for conditional areas. |
Intercepting routes ((.)photo), route modals |
A <dialog> in an island, with the open item in searchParams(); or an spa route whose layout keeps the list mounted while the detail page renders. |
ISR (revalidate), on-demand revalidation on the server |
Rebuild static pages, or render on the server and set cache-control: s-maxage=…, stale-while-revalidate=… for a CDN. |
| A prebuilt shell with per-request holes (PPR) | The two patterns in Close to partial prerendering. |
template.tsx |
None: page state resets on every navigation, and spa layouts keep theirs by design. |
| Middleware rewrites | None: redirect instead, or render the same component from both routes. |
Image optimization, next/font optimization |
None built in: see the next/image and next/font rows above. |
Generated images (opengraph-image.tsx, icon.tsx) |
Static files in public/, or an endpoint that returns the image. |
forbidden.tsx, unauthorized.tsx |
error(403) and error(401) render error.stsx, which reads status. |
| Draft mode | A server route that reads a preview cookie. |
| React component libraries | No React interop: native elements, web components or framework-agnostic libraries (see Coming from React). |
#Example
A blog post in Next.js: a server component, a streamed part, a client component and a server action.
// app/articles/[slug]/page.tsx
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { addComment, getComments, getPost } from "@/lib/posts";
import { Reactions } from "@/components/reactions"; // "use client" inside
export async function generateMetadata({ params }: PageProps<"/articles/[slug]">) {
const post = await getPost((await params).slug);
return { title: post?.title };
}
export default async function Article({ params }: PageProps<"/articles/[slug]">) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
async function comment(formData: FormData) {
"use server";
await addComment(slug, String(formData.get("text") ?? ""));
redirect(`/articles/${slug}`);
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<Reactions initial={post.likes} />
<Suspense fallback={<p>Loading comments…</p>}>
<Comments slug={slug} />
</Suspense>
<form action={comment}>
<textarea name="text" />
<button>Comment</button>
</form>
</article>
);
}
async function Comments({ slug }: { slug: string }) {
const comments = await getComments(slug);
return (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
);
}The same page in Lumiose. The data module stays on the server:
// src/lib/articles.server.ts: a database in a real app
interface Comment {
id: number;
text: string;
}
const articles = new Map([
["hello", { title: "Hello", body: "The first post.", likes: 3, comments: [] as Comment[] }],
]);
export async function getArticle(slug: string) {
return articles.get(slug);
}
export async function getComments(slug: string) {
await new Promise((resolve) => setTimeout(resolve, 300)); // a slow query
return articles.get(slug)?.comments ?? [];
}
export async function addComment(slug: string, text: string) {
articles.get(slug)?.comments.push({ id: Date.now(), text });
}The interactive part has no directive in its file:
// src/components/Reactions.tsx
import { signal } from "lumiose";
export function Reactions(props: { initial: number }) {
const likes = signal(props.initial);
return <button onClick={() => likes.update((n) => n + 1)}>Like ({likes()})</button>;
}The page renders on the server per request, sends its shell (title, article, form) at once and
streams the comments in. client:visible hydrates the button when it scrolls into view. The form
posts to the comment action and works without JavaScript:
// src/routes/articles/[slug]/page.stsx
import { Suspense, Title, href, notFound, redirect, type ActionContext, type PageProps } from "lumiose";
import { Reactions } from "../../../components/Reactions.tsx";
import { addComment, getArticle, getComments } from "../../../lib/articles.server.ts";
export const mode = "server";
export const actions = {
async comment(ctx: ActionContext<"/articles/[slug]">) {
const text = String((await ctx.formData()).get("text") ?? "").trim();
if (text !== "") await addComment(ctx.params.slug, text);
redirect(href("/articles/[slug]", { slug: ctx.params.slug }));
},
};
async function Comments(props: { slug: string }) {
const comments = await getComments(props.slug);
return (
<ul>
{comments.map((comment) => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
);
}
export default async function Article({ params }: PageProps<"/articles/[slug]">) {
const article = (await getArticle(params.slug)) ?? notFound();
return (
<article>
<Title>{article.title}</Title>
<h1>{article.title}</h1>
<p>{article.body}</p>
<Reactions client:visible initial={article.likes} />
<Suspense fallback={<p>Loading comments…</p>}>
<Comments slug={params.slug} />
</Suspense>
<form method="post" action="?/comment">
<textarea name="text" />
<button>Comment</button>
</form>
</article>
);
}A static page per slug, where Next.js uses generateStaticParams() and generateMetadata().
static is the default mode, so the page needs no mode export:
// src/routes/handbook/[slug]/page.tsx: built once per slug, served as files
import { Meta, Title, notFound, type PageProps } from "lumiose";
const chapters = new Map([
["intro", { title: "Introduction", summary: "Why the handbook exists." }],
["setup", { title: "Setup", summary: "Install and configure the tools." }],
]);
async function getChapter(slug: string) {
return chapters.get(slug); // a CMS or the file system in a real site
}
export function staticPaths() {
return [...chapters.keys()].map((slug) => ({ params: { slug } }));
}
export default async function Chapter({ params }: PageProps<"/handbook/[slug]">) {
const chapter = (await getChapter(params.slug)) ?? notFound();
return (
<main>
<Title>{chapter.title}</Title>
<Meta name="description" content={chapter.summary} />
<h1>{chapter.title}</h1>
</main>
);
}app/sitemap.ts becomes an endpoint, written to dist/client/sitemap.xml at build time:
// src/routes/sitemap.xml/route.ts
import { href, type Endpoint } from "lumiose";
const site = "https://example.com";
export const GET: Endpoint<"/sitemap.xml"> = () => {
const urls = ["intro", "setup"].map(
(slug) => `<url><loc>${site}${href("/handbook/[slug]", { slug })}</loc></url>`,
);
return new Response(
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join("")}</urlset>`,
{ headers: { "content-type": "application/xml" } },
);
};middleware.ts (proxy.ts) becomes src/middleware.ts for what runs on every request, and a
middleware.ts in a route folder for what a matcher scoped:
// src/middleware.ts
import type { Middleware } from "lumiose";
const middleware: Middleware = async (ctx, next) => {
const response = await next();
response.headers.set("x-frame-options", "DENY");
return response;
};
export default middleware;// src/routes/dashboard/middleware.ts: matcher "/dashboard/:path*", actions and endpoints included
import type { Middleware } from "lumiose";
const requireSession: Middleware<"/dashboard"> = (ctx, next) =>
ctx.cookies.get("session") === undefined
? Response.redirect(new URL("/login", ctx.url), 307)
: next();
export default requireSession;#Common mistakes
"use client"at the top of a file. It does nothing: the component still renders on the server only. Add aclient:*directive where the page uses it.- Reading cookies or
searchParamson astaticpage. Next.js would switch the page to dynamic rendering; Lumiose stops the build with LUM5001. Setexport const mode = "server". - Guarding a
staticpage in middleware. Prerendered pages are files in production, served without running middleware (it runs for them at build time; a folder middleware that covers them is LUM5008). Pages behind a sign-in areserverorsparoutes. loading.tsx,@modalor(.)photo. They are LUM5002 errors. Use<Suspense>, components in the layout, and a dialog or ansparoute.export const metadata. Nothing reads it. Render<Title>and<Meta>.<html>and<body>in the root layout. They belong insrc/document.stsx; a layout renders inside<body>.- Importing a server action into an island. There are no server functions: importing a
*.server.tsmodule from browser code is LUM4003. Post to a form action withsubmit(), or call an endpoint. - Expecting
fetchresults to be cached across requests. The cache lives for one request on the server. Cache with HTTP headers, or keep the pagestatic. await params. It works, butparamsis a plain object; readparams.slugdirectly.NEXT_PUBLIC_variables. Browser code seesimport.meta.env.PUBLIC_*only.
#Related
- Routing: file routes, layouts, params, error pages and typed
href(). - Islands:
client:*directives, serializable props and slots. - Rendering modes:
static,serverandsparoutes, streaming and the build. - Data and actions: endpoints, form actions,
submit()andinvalidate(). - Async components: server and client async components and the request cache.
- Head and document:
<Title>,<Meta>,<Head>anddocument.stsx. - Server: middleware, cookies, env and redirects.
- Deploy: Node, Docker, Vercel, Netlify, Cloudflare and static hosts.
- Coming from React: hooks, components and JSX.