---
title: "Single-page apps"
description: "How remix({ server: false }) sets up a client-rendered Remix 3 app, and what the plugin still contributes once there is no server."
url: "https://pitlane.tools/guides/spa"
---

# 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 })`](https://pitlane.tools/package/vite-plugin-remix/interface/RemixPluginOptions.md#server) is the switch for those apps.

```ts title="vite.config.ts"
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](https://pitlane.tools/guides/umbrella.md) 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](#client-rendering-with-a-server), 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](https://pitlane.tools/guides/prerendering.md) 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](https://pitlane.tools/guides/hmr.md#component-hmr). 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:

```tsx title="app/counter.tsx"
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](https://pitlane.tools/guides/hmr.md#state-survives-a-render-edit-and-resets-on-a-setup-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](https://pitlane.tools/guides/hmr.md#setup).

## The app shell

`index.html` is the entry, at the project root, loading one module:

```html
<!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>
```

```jsonc title="tsconfig.json"
{
    "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:

```tsx title="app/entry.browser.tsx"
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:

```tsx title="app/router.tsx"
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 />));
```

```tsx title="app/entry.browser.tsx"
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](#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:

```tsx title="app/router.tsx"
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>.css
```

The 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](https://pitlane.tools/deploy/github-pages.md) walks through the full workflow, including the base path a project site needs.

## Bundled dev mode

Vite's experimental [bundled dev mode](https://github.com/vitejs/vite/discussions/22746) 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:

```ts
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](https://github.com/vitejs/vite/discussions/22746), 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:

```ts title="app/assets.ts"
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");
```

```tsx title="app/shell.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](https://pitlane.tools/guides/vite-plugin.md#document-assets) 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:

```tsx title="app/entry.server.tsx"
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](#rendering) works here. A `createRoot` tree:

```tsx title="app/entry.browser.tsx"
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:

```tsx title="app/router.tsx"
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} />));
```

```tsx title="app/entry.browser.tsx"
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.
