---
title: "Using the Vite plugin"
description: "How the remix() plugin from pitlane/vite-plugin-remix behaves in your app day to day, across document assets, the clientEntry() transform and its footguns, dev-server semantics, preview, and the build."
url: "https://pitlane.tools/guides/vite-plugin"
---

# Using the Vite plugin

This guide covers living with [`remix()`](https://pitlane.tools/package/vite-plugin-remix/function/remix.md) from `pitlane/vite-plugin-remix` day to day. It explains how your document and islands find their browser assets and what the `clientEntry()` transform does, including what it deliberately refuses to do. Dev, preview, and the production build each get their own section. Hot module replacement has its own guide, [Hot module replacement](https://pitlane.tools/guides/hmr.md). Exact API signatures live in the generated [API reference](https://pitlane.tools/package/vite-plugin-remix/index.md). Per-target deployment configs live in the [deploy guides](#deploying).

## Installation

```sh
npm add pitlane
```

With yarn, pnpm, bun, vp, vlt, or nub, run the same command with the manager's name in place of `npm`. With deno, put its name in place of `npm` and write each package as `npm:<package>`.

The `pitlane` package goes in `dependencies` because app code imports runtime subpaths such as `pitlane/assets`. It requires `remix@^3.0.0` and `vite@>=8.1` as peer dependencies. The underlying `@pitlane/vite-plugin-remix` package also installs on its own, with the same peers. An app using it directly adds `@pitlane/assets` to `dependencies` for the resolver. The [Pitlane package guide](https://pitlane.tools/guides/umbrella.md) compares the two. The tested matrix is **Vite 8.1 and later** (Rolldown) and **Vite+ 1.0 and later** (`vp`) with `remix@3.0.0`. Vite 7 and 8.0 are not supported, and neither are the Vite+ 0.x releases built on them.

### Upgrading from `pitlane/dev`

The plugin was published as `@pitlane/dev` and `pitlane/dev` until this release. The rename comes with a new way to name assets, and there are no aliases for the old paths. Change these together:

| Before | After |
| --- | --- |
| `remix` from `pitlane/dev` | `remix` from `pitlane/vite-plugin-remix` |
| `revalidate` from `pitlane/dev/hmr` | `revalidate` from `pitlane/vite-plugin-remix/hmr` |
| `import clientAssets from "./entry.browser.ts?assets=client"`, then `.entry` | `(await assets.getScriptEntry("app/entry.browser.ts")).href` |
| `clientAssets.js` as `modulepreload` links | `.preloads` from the same call |
| `import serverAssets from "./entry.server.tsx?assets=ssr"`, then `.css` | `await assets.getStylesheets("app/entry.server.tsx")` |
| `mergeAssets(…)` from `pitlane/dev/runtime` | plain arrays; the resolver deduplicates |
| `render()` without options, or a hand-written `resolveClientEntry` | `render({ assets })` from `remix/middleware/render` |
| `"types": ["pitlane/dev/assets"]` in `tsconfig.json` | delete it |
| `import { HMR } from "pitlane:dev"` and `<HMR />` in the document | delete both; add the `server:update` listener from the [HMR guide](https://pitlane.tools/guides/hmr.md#setup) to the browser entry |

[Document assets](#document-assets) shows the resulting files.

### Upgrading from a Remix prerelease

Upgrade Remix and the Pitlane packages together. The stable-compatible releases require `remix@^3.0.0`. Their previous prerelease-compatible versions remain available for apps that have not migrated.

- Replace `remix/ui` imports with `remix/component`, including `/server`, `/jsx-runtime`, and `/jsx-dev-runtime` subpaths. Set TypeScript's `jsxImportSource` and Satteri's `mdx.jsxImportSource` to `remix/component`.
- Replace `remix/ui-hmr` with `remix/component-hmr`. For no-build apps, rename the asset loader from `uiHmr()` to `componentHmr()` and the Node hook to `remix/component-hmr/node`. The Vite plugin supplies its own HMR integration.
- Raw HTML props such as `innerHTML` now require `unsafeHTML()` from `remix/component`. It marks trusted or already-sanitized content. It does not sanitize strings. Pitlane's content renderer and `<Theme />` wrap their own markup this way, and neither sanitizes it.
- Animation utilities and headless primitives are separate `@remix-run/ui` imports. The old styled components and style mixins, including `button()` and `inputStyle`, were removed. Replacing those requires an application change.

See the [Remix 3.0.0 release notes](https://github.com/remix-run/remix/releases/tag/remix%403.0.0) for the upstream entrypoint changes.

## Templates

Prefer starting from a working app? The [`pitlane-tools/templates`](https://github.com/pitlane-tools/templates) monorepo has the same Remix 3 guest book wired for eight deploy targets. Cloudflare uses D1. Netlify uses Netlify DB, while Vercel pairs Nitro with Postgres. Railway runs on Node, Bun, or Deno. The remaining templates cover Deno Deploy and GitHub Pages, which runs in the browser with IndexedDB. Scaffold one with [giget](https://github.com/unjs/giget):

```sh
npx giget github:pitlane-tools/templates/<template> my-app
```

Because every template is the same app, diffing any two shows exactly what a platform swap touches, usually only the database middleware and deploy config. The templates still depend on the scoped `@pitlane/*` packages and import from them. Those imports keep working; to use the `pitlane/*` paths this guide shows, add `pitlane` and update the imports.

## The three-file core

Everything the plugin does orbits three files you own:

- **`vite.config.ts`** needs only `plugins: [remix()]`, and the defaults cover the rest.
- **`app/entry.server.tsx`** builds a router and **default-exports it**. The default export's `.fetch(Request)` is the contract every consumer reads: the dev server, `vite preview`, and whatever runs in production.
- **`app/entry.browser.ts`** calls `run()` from `remix/component` to hydrate `clientEntry()` components against server HTML.

A server-rendered app adds a fourth, `app/assets.ts`, which constructs the asset resolver your document and renderer share. See [Document assets](#document-assets).

`vite dev`, `vite build`, `vite preview`: the standard lifecycle, nothing bespoke.

## Options

Every option has a sensible default. Most projects pass none. [`RemixPluginOptions`](https://pitlane.tools/package/vite-plugin-remix/interface/RemixPluginOptions.md) documents each one.

```ts
remix({
    server: true, // false selects SPA mode
    prerender: undefined, // true, a path array, a function, or a config object
    clientEntry: "app/entry.browser", // false disables the client build
    serverEntry: "app/entry.server",
    serverEnvironments: ["ssr"],
    serverHandler: true, // false when a platform plugin serves dev and preview requests
    assets: {}, // { include, chunkImportMap, allowFiles, … } for the asset plugin; maps are on
});
```

| Option | Type | Default | Purpose |
| --- | --- | --- | --- |
| `server` | `boolean` | `true` | Whether the app has a server. Pass `false` for [SPA mode](#spa-mode), which ignores every option below. |
| `prerender` | `boolean \| string[] \| fn \| obj` | none | Render paths to static HTML at build time. See [Prerendering](https://pitlane.tools/guides/prerendering.md). |
| `clientEntry` | `string \| false` | `"app/entry.browser"` | Client entry module. Pass `false` for fully server-rendered apps with no hydration. |
| `serverEntry` | `string` | `"app/entry.server"` | Server entry module, built as `dist/ssr/index.js`, and the module dev requests are served through. |
| `serverEnvironments` | `string[]` | `["ssr"]` | Environment names treated as "server" by asset collection and hot module replacement. |
| `serverHandler` | `boolean` | `true` | Serve dev and `vite preview` requests through your server entry. Set `false` when `@cloudflare/vite-plugin` or `nitro/vite` owns request handling. |
| `assets` | `{ include?, chunkImportMap?, allowFiles?, allowPackages?, denyFiles? }` | `{}` | Passed to the asset plugin. `include` lists extra browser entries for computed paths. `chunkImportMap` switches [chunk import maps](#chunk-import-maps), and `remix()` defaults it to `true`. The allow and deny lists set up the [browser boundary](https://pitlane.tools/guides/assets.md#browser-boundary). |

## SPA mode

Some apps have no server: the router runs in the browser, the build is a folder of static files. `remix({ server: false })` targets those. React Router spells the same switch `ssr: false`.

```ts title="vite.config.ts"
export default defineConfig({
    plugins: [remix({ server: false })],
});
```

The three-file core collapses to one. `index.html` is the entry, and `vite build` emits a static site beside it. `app/entry.server.tsx`, the server environment, and `dist/ssr` are all gone. Every other option on this page goes unread, and the rest of this guide covers machinery a client-only app never reaches. [Single-page apps](https://pitlane.tools/guides/spa.md) is the guide for that mode.

The switch removes the server, not the server rendering. An app that wants a browser-rendered UI in front of routes that still run per request keeps the default mode: see [client rendering with a server](https://pitlane.tools/guides/spa.md#client-rendering-with-a-server).

## Document assets

Server-rendered HTML needs browser asset URLs that differ between `vite dev` and a build. The plugin supplies a manifest for the resolver from [`pitlane/assets`](https://pitlane.tools/guides/assets.md). Construct the resolver in `app/assets.ts`:

```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.ts");
export let stylesheets = await assets.getStylesheets("app/entry.server.tsx");
```

Paths are relative to the project root, the directory holding `vite.config.ts`. Writing `getScriptEntry` with a literal path makes that module a browser entry. `getStylesheets` only reports the CSS a module already imports and never sends server code to the browser. The [assets guide](https://pitlane.tools/guides/assets.md#resolver-methods) lists every method.

The document reads those values. Nothing in it is specific to Pitlane, and the same document works against `remix/assets` in a no-build app:

```tsx title="app/document.tsx"
import type { Handle, RemixNode } from "remix/component";

import { scriptEntry, stylesheets } from "./assets.ts";

export function Document(handle: Handle<{ children?: RemixNode }>) {
    return () => (
        <html lang="en">
            <head>
                <meta charSet="utf-8" />
                {stylesheets.map(href => (
                    <link key={href} rel="stylesheet" href={href} />
                ))}
                {scriptEntry.preloads.map(href => (
                    <link key={href} rel="modulepreload" href={href} />
                ))}
                <script type="module" src={scriptEntry.href} />
            </head>
            <body>{handle.props.children}</body>
        </html>
    );
}
```

The server entry hands the same object to Remix's own `render()` middleware, which uses it to resolve islands:

```tsx title="app/entry.server.tsx"
import { render } from "remix/middleware/render";
import { staticFiles } from "remix/middleware/static";
import { createRouter, type MiddlewareContext } from "remix/router";

import { assets } from "./assets.ts";
import { Document } from "./document.tsx";
import { routes } from "./routes.ts";

let renderMiddleware = render({ assets });
type AppContext = MiddlewareContext<[typeof renderMiddleware]>;

declare module "remix/router" {
    interface RouterTypes {
        context: AppContext;
    }
}

export let router = createRouter<AppContext>({
    middleware: [staticFiles("./dist/client"), renderMiddleware],
});

router.map(routes.home, ({ render }) => render(<Document>{/* ... */}</Document>));

export default router;

if (import.meta.hot) {
    import.meta.hot.accept();
}
```

> [!WARNING]
>
> **Footgun Warning**
>
> `render()` without `assets` renders pages until the first island, then fails with Remix's own error: `clientEntry() cannot use a file: source entry ID without an asset server. Pass the asset server to render({ assets }).` Pass the resolver.

### Dev and production resolve differently, by design

|  | `vite dev` | production build |
| --- | --- | --- |
| `scriptEntry.href` | source URL (`/app/entry.browser.ts`) | hashed chunk (`/assets/entry.browser-D3adB33f.js`) |
| `scriptEntry.preloads` | **always empty** (no chunk graph exists yet) | the entry chunk, then its static imports |
| `scriptEntry.importMap` | `{ imports: {} }` | the client build's import map, or `{ imports: {} }` with chunk import maps off |
| `getStylesheets(…)` | dev URLs for the CSS the server graph imports | hashed files in `dist/client` |

The same document works in both modes. Empty hint arrays produce no tags during development. Vite injects browser-only CSS and updates stylesheets in place when their rules change.

The manifest is server-side data. Import `app/assets.ts` from server-rendered modules such as your document, never from code that only runs in the browser.

### Chunk import maps

On by default. `remix()` turns on Vite's chunk import map for the client build, so a dependency's new hash changes one map entry instead of every chunk importing it. `scriptEntry.importMap` carries the complete map, and the document must deliver it before any module script or `modulepreload` link:

```tsx
import { ImportMap } from "remix/component/server";

// inside <head>, first:
<ImportMap value={scriptEntry.importMap} />;
```

The option requires browsers with import maps and `import.meta.resolve`, and conflicts with Vite's `experimental.renderBuiltUrl`. An app that uses `renderBuiltUrl`, or targets browsers without those features, turns maps off:

```ts
remix({ assets: { chunkImportMap: false } });
```

Chunks then import each other by their final URLs, and `scriptEntry.importMap` stays `{ imports: {} }`. A plugin value wins over Vite's own `build.chunkImportMap` in the client environment. With no plugin value, a native setting applies. The [assets guide](https://pitlane.tools/guides/assets.md#chunk-import-maps) covers the caching limits and renderers other than Remix.

## The `clientEntry()` transform

`clientEntry(import.meta.url, …)` marks a component for hydration. The plugin rewrites the first argument into a portable identity: `file:` plus the module's path from the project root, plus an `#ExportName` fragment. Every environment, in dev and in the build, gets the same literal:

```diff
export let Counter = clientEntry(import.meta.url, function Counter(handle) {
export let Counter = clientEntry("file:app/counter.tsx#Counter", function Counter(handle) {
```

When a page renders `<Counter />`, `render({ assets })` sees the `file:` prefix and asks the resolver for the island's script entry. In dev that is the module's own URL, `/app/counter.tsx`. In a build it is the island's emitted chunk, and the renderer hoists the chunk and its static imports into the head as `modulepreload` links. The hydration record still names the chunk URL and export, and the browser's `run()` imports it and hydrates in place.

```tsx
import { clientEntry, on } from "remix/component";

export let Counter = clientEntry(import.meta.url, handle => {
    let count = 0;
    return () => (
        <button
            mix={[
                on("click", () => {
                    count++;
                    handle.update();
                }),
            ]}
        >
            Count: <span>{count}</span>
        </button>
    );
});
```

Each island module becomes its own client chunk with its exports intact, even when no browser code imports it: the plugin finds islands while building the server and emits them in the client build. Multiple `clientEntry` exports in one file share that chunk, so grouping related islands per file is free.

`vite dev` follows the same rule without a build. When the server imports a module that declares an island, the plugin registers that module as a browser entry. The resolver then answers with its dev URL. Delete the module's last `clientEntry` export and the registration goes with it on the next edit. An island file outside the server graph is not an entry.

The identity contains no checkout directory, so builds on different machines write the same server code. A module linked from outside the project root, such as a workspace package, keeps leading `../` segments (`file:../packages/ui/src/counter.tsx#Counter`), which assumes the same relative workspace layout on every machine that builds it.

### The pattern is strict

The transform matches **exactly** this shape, at the top level of a module:

```
export let Name = clientEntry(import.meta.url, …at least one more argument)
```

The declaration kind is the one part that is free. `let`, `const`, and `var` all match, because the `#Name` fragment comes from the name the export binds and nothing else. Examples here use `let`, matching the rest of the codebase.

Anything else is left untouched. An untransformed call keeps `import.meta.url`, which on Node is the module's absolute `file:///` URL. `render({ assets })` passes it to the resolver, which rejects absolute file URLs and asks for a portable source key, so the page fails to render. If you see that error for an island, check these first:

```tsx
// ✗ default export — no export name for the #fragment
export default clientEntry(import.meta.url, handle => {
    /* … */
});

// ✗ aliased callee — the transform matches the literal name `clientEntry`
import { clientEntry as ce } from "remix/component";

export let Counter = ce(import.meta.url, handle => {
    /* … */
});

// ✗ not exported — the browser could never import it by name
let Counter = clientEntry(import.meta.url, handle => {
    /* … */
});

// ✗ wrapped in a helper — the call site the transform sees isn't clientEntry()
export let Counter = myIslandHelper(import.meta.url, handle => {
    /* … */
});

// ✗ computed first argument — must be literally `import.meta.url`
export let Counter = clientEntry(String(import.meta.url), handle => {
    /* … */
});

// ✓ the correct shape
export let Counter = clientEntry(import.meta.url, handle => {
    /* … */
});

// ✓ the correct shape
export const Counter = clientEntry(import.meta.url, handle => {
    /* … */
});

// ✓ the correct shape
export var Counter = clientEntry(import.meta.url, handle => {
    /* … */
});
```

Only a top-level export binding whose initializer is a literal `clientEntry(import.meta.url, …)` call is rewritten. The `#Name` fragment names a real export, and the explicit pattern cannot false-positive on unrelated `import.meta.url` usage in the same file (which the transform leaves alone).

### `serverEnvironments`

The plugin needs to know which environments are "server". Islands are discovered while those environments build, and their stylesheets are collected from those graphs. The default, `["ssr"]`, is right unless you've renamed or added server environments. If you have, pass the same names to `remix({ serverEnvironments })`. [Hot module replacement](https://pitlane.tools/guides/hmr.md) reads the same option to tell server modules from client ones, so a mismatch breaks both.

## Dev server semantics

With the default `serverHandler: true`, every request is answered by your server entry through Vite's module runner: `serverEntry`, so `app/entry.server` unless you set another path. The entry is re-imported per request, so server-side edits are live without restarting. The `if (import.meta.hot) import.meta.hot.accept()` line in the server entry is what keeps those re-imports cheap, so keep it.

The request bridge is the [Fetch server plugin](https://pitlane.tools/guides/fetch-server.md), which `remix()` installs for you with your `serverEntry`. Vite handles module requests and static files first. Everything else reaches your router.

> [!NOTE]
>
> **Hot module replacement**
>
> Component edits swap in place and keep live island state, and edits to server-only modules refetch the page through your fetch handler without discarding that state. Component HMR is on by default in `vite dev`. Server-data revalidation also needs a short listener in your browser entry. See [Hot module replacement](https://pitlane.tools/guides/hmr.md) for the listener, which edits preserve state, and which remount.

- **Set `serverHandler: false` when a platform plugin owns requests.** `@cloudflare/vite-plugin` (workerd) and `nitro/vite` bring their own runtimes, for dev and for preview. Netlify's plugin does not serve SSR, so keep the default there. Each [deploy guide](#deploying) states the right value.
- **Aborted requests stay quiet.** Search-as-you-type and mid-navigation fetch cancellations end the response without reaching Vite's error overlay, and the next request is served normally. Real errors still surface.

## Preview and the static-files footgun

`vite preview` imports `dist/ssr/index.js` and serves requests through its default export, the same artifact that production runs. With `serverHandler: false`, the plugin installs no preview server at all, and the platform plugin's preview serves the build in the runtime that runs it in production. That holds for a self-contained Worker bundle too, even though Node could load it. With the default `serverHandler: true`, a failed import is the signal instead: a bundle targeting another runtime (a Workers bundle importing `cloudflare:workers`) makes the plugin step aside so the platform's preview can take over.

> [!WARNING]
>
> **Footgun Warning**
>
> In dev, Vite serves every module and stylesheet itself. In preview and production, hashed assets under `dist/client` are served by **your router's** `staticFiles("./dist/client")` middleware (on platforms without a static layer, such as Node, Railway images, and preview). Forget the middleware and everything works in dev while every asset 404s in preview. That missing middleware is the usual cause of an unstyled preview. Platforms with their own static serving (Workers assets, Netlify's CDN, Vercel) bypass the middleware for those paths. Keeping it in the router is still correct and makes preview match production.

## The build

`vite build` runs a coordinated multi-environment build:

```
dist/
├── client/          # hashed static assets
│   └── assets/*
└── ssr/
    └── index.js     # the fetch handler
```

`dist` is Vite's `build.outDir`. Set it and both environments move beneath it, to `<outDir>/client` and `<outDir>/ssr`, so point your router's `staticFiles()` middleware at the new client directory. Setting `environments.client.build.outDir` or `environments.ssr.build.outDir` places that environment's output exactly where you name instead.

- **Server environments build first, then the client.** Islands and literal `getScriptEntry` paths are discovered while the server builds, and the client build emits them. The plugin sequences it and writes the asset manifest into `dist/ssr` before anything else reads the build, so don't hand-orchestrate builds around it.
- **Server-imported stylesheets are copied into `dist/client`**, along with the fonts and images they reference and any file the server code imports, so every URL `getStylesheets` returns is served from the client output. Nothing else from the server output is copied. Server sourcemaps stay private, and so do files a platform plugin writes beside the server bundle, like `.dev.vars` or `wrangler.json`.
- **Composing with another build orchestrator is coordinated.** When a platform plugin also drives builds (Cloudflare's does), each environment still builds exactly once, and [prerendering](https://pitlane.tools/guides/prerendering.md) runs after the manifest is complete.

## Compatibility

| Dependency | Tested against |
| --- | --- |
| `vite` | 8.1 and the latest 8.x |
| `vite-plus` | 1.0 |
| `remix` | 3.0.0 |
| Node | 24 LTS, 26 |

Each `@pitlane/vite-plugin-remix` release records the exact Remix version it was verified against. The transform runs identically on generic Vite and Vite+.

### Troubleshooting

**`vp` stops with `Expected @voidzero-dev/vite-plus-core@…, but found vite@…`**. Vite+ 1.0 requires the `vite` your project declares to be Vite+ core at the same version as `vite-plus`. Alias it to the core. `vp migrate` sets up the same alias, pinned to the exact version:

```jsonc title="package.json"
{
    "devDependencies": {
        "vite": "npm:@voidzero-dev/vite-plus-core@^1.0.0",
        "vite-plus": "^1.0.0",
    },
}
```

**pnpm reports the `vite >=8.1.0` peer as unmet under that alias**. `@voidzero-dev/vite-plus-core` reports its own version (`1.x`), not the Vite version it bundles, so the peer range cannot match it. Tell pnpm which versions of `vite` satisfy it:

```yaml title="pnpm-workspace.yaml"
peerDependencyRules:
    allowedVersions:
        vite: "1"
```

Bun and npm install without complaint.

**`AssertionError: isRunnableDevEnvironment(environment)` on dev**. The project resolves two different `vite` packages, typically because a dependency installs a plain `vite` beside the alias. Override `vite` with the same alias in your package manager's configuration, such as `overrides` in `pnpm-workspace.yaml`, so the whole project resolves one copy.

Generic-Vite projects have one vite by construction and are unaffected.

## Deploying

Once the app works, pick a target. Each guide covers config, CLI deploys, and a GitHub Actions workflow, all with the Vite build running in your CI:

- [Cloudflare Workers](https://pitlane.tools/deploy/cloudflare.md)
- [Netlify](https://pitlane.tools/deploy/netlify.md)
- [Vercel](https://pitlane.tools/deploy/vercel.md)
- [Railway](https://pitlane.tools/deploy/railway.md)
- [Deno Deploy](https://pitlane.tools/deploy/deno-deploy.md)
- [GitHub Pages](https://pitlane.tools/deploy/github-pages.md) (client-only apps)
