Using the Vite plugin
This guide covers living with remix() 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. Exact API signatures live in the generated API reference. Per-target deployment configs live in the deploy guides.
Installation
npm
npm add pitlaneyarn
yarn add pitlanepnpm
pnpm add pitlanebun
bun add pitlanedeno
deno add npm:pitlanevp
vp add pitlanevlt
vlt add pitlanenub
nub add pitlaneThe 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 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 to the browser entry |
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/uiimports withremix/component, including/server,/jsx-runtime, and/jsx-dev-runtimesubpaths. Set TypeScript'sjsxImportSourceand Satteri'smdx.jsxImportSourcetoremix/component. - Replace
remix/ui-hmrwithremix/component-hmr. For no-build apps, rename the asset loader fromuiHmr()tocomponentHmr()and the Node hook toremix/component-hmr/node. The Vite plugin supplies its own HMR integration. - Raw HTML props such as
innerHTMLnow requireunsafeHTML()fromremix/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/uiimports. The old styled components and style mixins, includingbutton()andinputStyle, were removed. Replacing those requires an application change.
See the Remix 3.0.0 release notes for the upstream entrypoint changes.
Templates
Prefer starting from a working app? The 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:
npx giget github:pitlane-tools/templates/<template> my-appBecause 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.tsneeds onlyplugins: [remix()], and the defaults cover the rest.app/entry.server.tsxbuilds 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.tscallsrun()fromremix/componentto hydrateclientEntry()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.
vite dev, vite build, vite preview: the standard lifecycle, nothing bespoke.
Options
Every option has a sensible default. Most projects pass none. RemixPluginOptions documents each one.
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, which ignores every option below. |
prerender |
boolean | string[] | fn | obj |
none | Render paths to static HTML at build time. See Prerendering. |
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, and remix() defaults it to true. The allow and deny lists set up the 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.
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 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.
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. Construct the resolver in 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 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:
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:
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();
}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:
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:
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 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:
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.
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:
// ✗ 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 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, which remix() installs for you with your serverEntry. Vite handles module requests and static files first. Everything else reaches your router.
- Set
serverHandler: falsewhen a platform plugin owns requests.@cloudflare/vite-plugin(workerd) andnitro/vitebring their own runtimes, for dev and for preview. Netlify's plugin does not serve SSR, so keep the default there. Each deploy guide 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.
The build
vite build runs a coordinated multi-environment build:
dist/
├── client/ # hashed static assets
│ └── assets/*
└── ssr/
└── index.js # the fetch handlerdist 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
getScriptEntrypaths are discovered while the server builds, and the client build emits them. The plugin sequences it and writes the asset manifest intodist/ssrbefore 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 URLgetStylesheetsreturns 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.varsorwrangler.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 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:
{
"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:
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
- Netlify
- Vercel
- Railway
- Deno Deploy
- GitHub Pages (client-only apps)