Single-page apps
Some Remix 3 apps have no server. The router runs in the browser. The build is a folder of static files, and the host is a CDN. remix({ server: false }) is the switch for those apps.
import { remix } from "pitlane/vite-plugin-remix";
import { defineConfig } from "vite"; // or "vite-plus"
export default defineConfig({
plugins: [remix({ server: false })],
});The pitlane package provides pitlane/vite-plugin-remix. If only vite.config.ts imports it, the app can install pitlane as a dev dependency. The Pitlane package guide covers its remix and vite requirements and the standalone @pitlane/vite-plugin-remix alternative.
If you want browser-only UI in front of server routes that still run per request, that is the default mode, not this one.
Nothing in this mode renders outside a browser, so there is no server-safe rendering to worry about. index.html is served as written. Prerendering needs a server to render through, and remix({ server: false, prerender }) throws.
What the switch changes
server: true (default) |
server: false |
|
|---|---|---|
| Vite Environments | client and ssr |
client |
| Build input | clientEntry and serverEntry |
index.html |
| Build output | dist/client and dist/ssr |
dist |
| Dev requests | your fetch handler | Vite's static server |
| Component HMR | yes | yes |
| Server-data HMR | yes | no server data exists |
| Asset resolver | how HTML names its client assets | Vite injects the tags |
| Chunk import map | on; the document renders it | on; Vite writes it into index.html |
Every server* option goes with it: serverEntry, serverEnvironments, and serverHandler describe a server that no longer exists. clientEntry goes too, because the browser entry is whatever index.html loads. Of the assets options, only chunkImportMap still applies. remix({ server: false, assets: { chunkImportMap: false } }) turns the map off.
Why use the plugin at all
Vite already serves index.html in dev and builds it into a static site. The plugin's remaining job is component hot module replacement. Without HMR, editing a component reloads the page and every piece of live state goes with it (a half-typed form, an open menu, a scroll position). With it, the component swaps in place and that state survives.
Both authoring styles hot-swap. The plugin rewrites an arrow-form component export to a function expression with a name before instrumenting it:
import { on, type Handle } from "remix/component";
export function Counter(handle: Handle) {
let count = 0;
return () => (
<button
mix={[
on("click", () => {
count++;
handle.update();
}),
]}
>
Count: {count}
</button>
);
}Editing the returned markup swaps it in and the count keeps counting. Editing the setup scope above the return remounts the component, which is the same rule as everywhere else. See state survives a render edit.
Server-data revalidation has no server to revalidate against, so the plugin never broadcasts a server update here and the browser entry needs no server-update listener.
The app shell
index.html is the entry, at the project root, loading one module:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Remix App</title>
<script type="module" src="/app/entry.browser.tsx"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "remix/component",
},
}Rendering
Two shapes work, depending on whether the app needs routing.
A root component
createRoot from remix/component renders a component tree into an element. There is no routing, and the URL never changes:
import { createRoot } from "remix/component";
import { App } from "./app.tsx";
let container = document.getElementById("app");
if (!container) throw new Error("missing #app container");
createRoot(container).render(<App />);A router
remix/spa connects an ordinary Remix fetch router to the document. The render() middleware gives every handler a context.render() that answers with a UI node, and run() dispatches the current URL plus every same-origin navigation and form submission through that router:
import { createRouter } from "remix/router";
import { render } from "remix/spa";
export let router = createRouter({ middleware: [render()] });
router.get("/", ({ render }) => render(<Home />));
router.get("/about", ({ render }) => render(<About />));import { run } from "remix/spa";
import { Loading } from "./loading.tsx";
import { router } from "./router.tsx";
let app = run(router, { fallback: <Loading /> });
await app.ready();run() renders into the document's top frame. Whatever the shell left in <body> goes away with the first render, so the #app container from the app shell is createRoot machinery this shape never reads.
The optional fallback is a live node for the gap before the first route resolves. app.ready() waits for the routed node to take its place.
Everything past the first render stays inside the router. Anchors, form submissions, and history traversal become Request objects it answers, and a handler that returns a redirect() is followed before anything renders. Redirects have to stay same-origin, and a chain longer than ten throws.
Give render() a transform when every route shares a shell. It receives the route's node and the request context, so the layout can read the URL it is rendering:
export let router = createRouter({
middleware: [
render((content, { url }) => (
<main>
<Nav pathname={url.pathname} />
{content}
</main>
)),
],
});The build
vite build produces a static site and no server bundle:
dist/
├─ index.html
└─ assets/
├─ index-<hash>.js
└─ index-<hash>.cssThe HMR instrumentation is dev-only, so none of it reaches those chunks.
Serving it
A SPA answers every URL with the same HTML and lets the client sort out the rest. vite preview already does that, so a deep link works locally without configuration. Static hosts need telling:
| Host | Fallback |
|---|---|
| GitHub Pages | copy index.html to 404.html after the build |
| Netlify | /* /index.html 200 in _redirects |
| Cloudflare | /* /index.html 200 in _redirects |
| Nginx or Caddy | try_files $uri /index.html |
The GitHub Pages guide walks through the full workflow, including the base path a project site needs.
Bundled dev mode
Vite's experimental bundled dev mode serves your app as a Rolldown bundle during vite dev instead of as unbundled modules. Pitlane's SPA mode works with bundled dev mode today, HMR included:
export default defineConfig({
plugins: [remix({ server: false })],
experimental: {
bundledDev: true,
},
});Bundled dev mode does not yet support SSR apps in its experimental form. Bundled dev mode serves bundle entrypoints only, so the client module URLs a server-side renderer writes into its HTML resolve to nothing. Upstream tracks server environments as Phase 4 of the bundled-dev roadmap, and it is still a prototype.
Client rendering with a server
If you want a browser-rendered UI in front of routes that still run on the server, leave the default server setting alone for the plugin. Serving a client-rendered app from a server with server routes is easy in Remix:
The shell
The document the server sends for every navigable route, written as a component rather than a template literal, so the markup is typed and the asset URL comes from the asset resolver:
import { createAssetResolver } from "pitlane/assets";
import manifest from "pitlane/assets/manifest";
export let assets = createAssetResolver(manifest);
export let scriptEntry = await assets.getScriptEntry("app/entry.browser.tsx");import { scriptEntry } from "./assets.ts";
export function Shell() {
return () => (
<html lang="en">
<head>
<meta charSet="utf-8" />
<title>My app</title>
<script src={scriptEntry.href} type="module" />
</head>
<body>
<div id="app" />
</body>
</html>
);
}scriptEntry.href is the URL of app/entry.browser.tsx: the dev URL during vite dev and the hashed chunk after a build. That one call is the whole reason the shell does not need to know its own build output. The Vite plugin guide covers the resolver, including stylesheets and preloads for apps that need them.
The server
renderToString from remix/component/server turns the shell into HTML, and createHtmlResponse puts the DOCTYPE and the content type on it. Data routes answer next to it, in the same router:
import { createHtmlResponse as html } from "remix/response/html";
import { createRouter } from "remix/router";
import { renderToString } from "remix/component/server";
import { getPosts } from "./posts.ts";
import { routes } from "./routes.ts";
import { Shell } from "./shell.tsx";
export let router = createRouter();
router.map(routes.posts, () => Response.json(getPosts()));
async function shell(): Promise<Response> {
return html(await renderToString(<Shell />));
}
router.map(routes.home, () => shell());
router.map(routes.post, () => shell());
export default router;renderToString renders the element tree and stops there. It emits no DOCTYPE, which is what createHtmlResponse is for. A shell without one puts the browser in quirks mode.
The browser
This is what scriptEntry.href loads, and it is the same code SPA mode runs. Either shape from Rendering works here. A createRoot tree:
import { createRoot } from "remix/component";
import { App } from "./app.tsx";
let container = document.getElementById("app");
if (!container) throw new Error("missing #app container");
createRoot(container).render(<App />);Or a client router through remix/spa, which is the shape that earns the shell in the first place, because now the URL means something:
import { createRouter } from "remix/router";
import { render } from "remix/spa";
export let router = createRouter({ middleware: [render()] });
router.get("/", ({ render }) => render(<Home />));
router.get("/posts/:id", ({ render, params }) => render(<Post id={params.id} />));import { run } from "remix/spa";
import { router } from "./router.tsx";
let app = run(router);
await app.ready();Two routers, and they are not the same one. The browser router owns what the user sees. The server router owns the shell and the data those views fetch. Both meet at the URL, so the server maps every navigable route to the shell. A deep link or a refresh has to arrive at HTML that boots the client router, which then resolves the path.
The shell's <div id="app" /> is there for the createRoot shape. Under remix/spa, run() renders into the top frame and replaces the body the server sent, so the container goes unread and an empty <body> works just as well.