@solidjs/vite-plugin
v3.0.0-next.47
Published
solid-js integration plugin for Vite
Readme
⚡ @solidjs/vite-plugin
Renamed from
vite-plugin-solid. This package was previously published asvite-plugin-solid. To migrate, swap the dependency and the import —npm install -D @solidjs/vite-pluginandimport solid from '@solidjs/vite-plugin'— nothing else changes.
A simple integration to run solid-js with vite
Got a question? / Need help?
Join solid discord and check the troubleshooting section to see if your question hasn't been already answered.
Features
- HMR with no configuration needed
- Drop-in installation as a vite plugin
- Minimal bundle size
- Support typescript (
.tsx) out of the box - Experimental TypeScript TSRX (
.tsrx) support out of the box - Support code splitting out of the box
Requirements
This module is 100% ESM compatible and requires Node.js >=22.12.0 (the same
floor as the solid-js 2.0 runtime packages).
You can check your current Node.js version by running node -v. Use a version
manager such as Volta or nvm
to update it.
Supported Vite versions: Vite 8 and 9.
Quickstart
You can use the vite-template-solid starter templates similar to CRA:
$ npx degit solidjs/templates/js my-solid-project
$ cd my-solid-project
$ npm install # or pnpm install or yarn install
$ npm run start # starts dev-server with hot-module-reloading
$ npm run build # builds to /distInstallation
Install vite, @solidjs/vite-plugin as dev dependencies.
Install solid-js as dependency.
You have to install those so that you are in control to which solid version is used to compile your code.
# with npm
$ npm install -D vite @solidjs/vite-plugin
$ npm install solid-js
# with pnpm
$ pnpm add -D vite @solidjs/vite-plugin
$ pnpm add solid-js
# with yarn
$ yarn add -D vite @solidjs/vite-plugin
$ yarn add solid-jsAdd it as plugin to vite.config.js
// vite.config.ts
import { defineConfig } from 'vite';
import solidPlugin from '@solidjs/vite-plugin';
export default defineConfig({
plugins: [solidPlugin()],
});Run
Just use regular vite or vite build commands
{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}API
options
- Type: Object
- Default: {}
options.include
- Type: (string | RegExp)[] | string | RegExp | null
- Default: undefined
A picomatch pattern, or array of patterns, which specifies the files the plugin should operate on.
options.exclude
- Type: (string | RegExp)[] | string | RegExp | null
- Default: undefined
A picomatch pattern, or array of patterns, which specifies the files to be ignored by the plugin.
options.dev
- Type: Boolean
- Default: true
Resolve Solid's development builds under vite dev — the development export condition of
solid-js and @solidjs/web, which carry the extra checks, warnings and diagnostics. Has no
effect on vite build. Set to false to serve the production builds in dev instead.
options.observe
- Type: Boolean
- Default: false
Resolve Solid's observe builds in production: the production-speed runtime that keeps the
diagnostics and attribution channels (OBSERVE) alive for observability tooling (error
monitoring, performance tracing). Adds the observe export condition to every environment
and turns on the compiler's source names (see options.solid.sourceNames), so graph labels
(<Home>, span.textContent, count) survive minification. Under vite dev the dev build
still wins.
options.performanceTracks
- Type:
boolean | PerformanceTracksOptions(from@solidjs/web/performance-tracks) - Default: true
Dev-serve only: paint Solid's records — effect and memo re-runs, interactions, holds, async
flights, navigations, server-function calls — as custom tracks in the Chrome Performance
panel, without the app calling enablePerformanceTracks() itself. Record a trace with the
Performance panel while the dev server serves the page; the Solid group appears beside
Chrome's main-thread and network tracks (Interactions, Propagation, Effects, Memos,
Async, Holds, Navigations, Server), every span labelled by why it ran, findings as
markers on the Timings track. See Chrome Performance panel
in the Solid 2.0 diagnostics guide for how to read them.
The plugin injects a small client module that enables the tracks ahead of the app's entry
— a <script type="module"> prepended to <head> for index.html apps, the first import
of the client entry (generated or authored) in start mode — so hydration and the first
interaction are on the timeline. The dev server needs nothing: it already writes its side
of the work (Server-Timing: solid-shell, solid-boundary, solid-invocation), which the
tracks read back onto the Server track.
| Value | vite dev | vite build / preview / vitest |
| --- | --- | --- |
| omitted or true | enabled, adapter defaults | off |
| { minMs, rich, attribution } | enabled with those options | off |
| false | off | off |
The object form passes PerformanceTracksOptions through — minMs (floor for run spans),
rich (performance.measure with tooltips and properties vs console.timeStamp), and
attribution (the options of the engine hold the adapter takes: thresholds and the values
level). All plain data: the options are serialized into the injected module. Never active on
vite build — not for dev: true or observe builds; an observe app that wants tracks in
production calls enablePerformanceTracks() itself — and never in test mode or preview.
With dev: false (and no observe) the production runtime is served under vite dev and
the adapter is a no-op. Requires the installed @solidjs/web to export ./performance-tracks (every version
in the peer range does); an older install warns once at startup and skips the injection.
options.hot
- Type: Boolean
- Default: true
This will inject HMR runtime in dev mode. Has no effect in prod. If set to false, it won't inject the runtime in dev.
options.ssr
- Type: Boolean
- Default: false
Whether the app is server-rendered — one meaning everywhere.
Without start, ssr: true enables the SSR transforms
(hydratable client code, SSR server code); you provide the entries and the
server yourself, as before. With start, the boolean selects the start
mode: ssr: true is SSR start mode, ssr: false/omitted is client start
mode — see below.
Objects are no longer accepted (config-time error): the start-mode options
that used to live on ssr: { ... } moved to start: { ... }, with
ssr: true set alongside.
options.start
- Type: Boolean | Object
- Default: undefined
Start is now a mode of the plugin: the serving layer that
replaces SolidStart. The plugin owns entries, dev serving, and the build —
no entry files, no index.html, no dev server script. start: true is the
zero-config spelling; add ssr: true for streaming SSR:
// vite.config.ts
import { defineConfig } from 'vite';
import solidPlugin from '@solidjs/vite-plugin';
export default defineConfig({
plugins: [solidPlugin({ start: true, ssr: true })],
});One set of conventions serves both rendering modes, and the
ssr boolean picks between them: ssr: true streams
server-side rendering with zero wiring; without it the same app is
client-rendered onto a prerendered static shell. Flipping a project between
SPA and SSR is toggling that one boolean — same App, same Document,
same server functions.
The object form carries the options (start: true is pure sugar for
start: {} — both mean the identical start mode with defaults, and
false/absent means off): app, document, entryServer, entryClient,
middleware, setup, renderMode, env, devtools, errorBoundary,
css, external, node, all documented below.
Install @solidjs/start-devtools as a development dependency to add the
development toolbar with runtime errors and server function calls:
pnpm add -D @solidjs/start-devtools@nextStart mode detects the package automatically. Set start: { devtools: true }
to require it or start: { devtools: false } to disable automatic integration.
The package is an optional peer and the toolbar is not included in production
builds.
Generated entries wrap the app automatically. With custom server and client entries, place the development boundary around the app in the shared document or root:
import { DevToolbar } from "@solidjs/start-devtools";
<body>
<DevToolbar>
<App />
</DevToolbar>
</body>;The package becomes a children-only passthrough in production, so no toolbar code is included in either production bundle.
// src/App.tsx — the entire app: a plain content component
export default function App() {
return <h1>Hello SSR</h1>;
}With ssr: true — SSR start mode:
- Dev:
vitejust works — a middleware on the dev server streams the rendered app for HTML-accepting GET requests through the SSR environment, injecting the Vite client (HMR, error overlay) and the dev style patch into<head>. SSR errors render Vite's error page with the overlay. The client entry enables the Chrome Performance panel tracks before it hydrates (seeperformanceTracks). - Build: a plain
vite buildproduces both bundles via the environments/builder API — client assets (+ manifest) todist/clientand the server bundle todist/server/server.js. (vite build --app, or the classicvite build+vite build --ssrtwo-step, work too.) - Build ordering: server builds read the client manifest, so with
ssrenabled the plugin also orders builder-mode (environments API) app builds client-first via abuildApphook. That covers composed setups whose own orchestrator builds server environments before the client — e.g. @cloudflare/vite-plugin — with no hand-written ordering plugin; setups without another orchestrator keep Vite's stock build-everything behavior, just client-first. - Prod: the server bundle's entry is
virtual:solid-ssr-handler. Its namedhandleRequest(request)export maps a web-standardRequestto a streamedResponse; its default{ fetch(request) }export provides the same handler in the Fetchable shape used by Workers, Nitro, Netlify Functions, Bun, anddeno serve:
import app, { handleRequest } from './dist/server/server.js';
// serve dist/client statically, everything else:
const response = await handleRequest(request);
const sameResponse = await app.fetch(request);The Fetchable wrapper deliberately accepts only the request. Hosts may pass
environment or execution-context arguments after it; those are not the
Solid options accepted by handleRequest's second parameter.
- Deploying to Node —
node: Node is the one mainstream runtime without a fetch-shaped server API (Workers, Deno, Bun, Netlify, and Nitro consume{ fetch }directly), so the build can emit the Node server for you. Withstart: { node: true }the ssr build writesdist/server/node.jsbesideserver.js, and the production start command is:
node dist/server/node.js # PORT (default 3000), HOSTThe entry serves dist/client statically — files under
build.assetsDir as Cache-Control: public, max-age=31536000, immutable
(their names are content hashes), everything else public, max-age=0,
must-revalidate with Last-Modified; HEAD supported, dot-segment
paths and .. traversal refused — and hands every other request to
handleRequest with the raw Node request as nativeEvent, so
getRequestEvent().nativeEvent is the IncomingMessage (client IP:
event.nativeEvent.socket.remoteAddress; behind a proxy read the
forwarding headers off event.request instead, only when you trust the
proxy). The node<->web bridge is the plugin's own — the same code the dev
and preview middlewares run: HTTP/2 pseudo-headers, https: on TLS
sockets, client disconnects as the request's AbortSignal, HEAD
short-circuit, set-cookie split, backpressure that also settles when
the client goes away. Errors log to console.error and answer 500. The
file is ESM, depends on nothing but node:* and ./server.js, and
exports listener — the (req, res) function — plus
createListener(options?) and serve(options?), so it composes with an
existing server. With Express, either let the entry serve everything or
keep only the bridge behind Express's own static handling:
import express from 'express';
import compression from 'compression';
import { listener, createListener } from './dist/server/node.js';
const app = express();
app.use(compression());
app.use(listener); // static + pages + server functions
// — or — let Express own static files (point it at dist/client):
app.use(express.static('dist/client', { immutable: true, maxAge: '1y' }));
app.use(createListener({ static: false }));
app.listen(process.env.PORT || 3000);createListener({ static: false }) skips the file lookup and, in client
mode, the index.html history fallback — the framework owns both.
createListener({ event: (req) => ({ ... }) }) merges extra fields over
{ nativeEvent: req } into the request event. serve({ port, host, static,
event }) takes the same two options on top of the listen address.
node.js is an emitted asset, not a second build input: server.js and
its handleRequest / { fetch } contracts are unchanged. It applies to
both start modes — in client mode with serverFunctions (which keeps
dist/server) the entry serves the static client with an index.html
history fallback for HTML navigations plus the endpoint. Nothing is
emitted where no server bundle exists (client mode without
serverFunctions, or start.external); the build warns. The
compression/proxy stance is unchanged: the entry speaks plain HTTP —
terminate TLS and compress at the reverse proxy or CDN in front of it.
Among the handleRequest options, event is the supported public seam
for extending the request event: its fields spread into the event at
creation, so a custom server entry (or a host wrapper) can attach whatever
its platform knows and read it back anywhere in the request scope with
getRequestEvent(). The conventional field name is nativeEvent — the
platform's raw request object. This is what the emitted node.js does; a
hand-written Node entry (the custom-server recipe) passes the
IncomingMessage the same way:
import { createServer } from 'node:http';
import { handleRequest } from './dist/server/server.js';
createServer(async (req, res) => {
const response = await handleRequest(webRequest(req), {
event: { nativeEvent: req },
});
// ... write response to res
});// anywhere inside the request scope (middleware, setup, app code)
import { getRequestEvent } from '@solidjs/web';
const event = getRequestEvent();
event.nativeEvent; // the Node IncomingMessage the entry passedThe plugin's own dev and preview middlewares (and the server-function dev
middleware) pass event: { nativeEvent: req } with the Node request, so
getRequestEvent().nativeEvent answers the same under vite dev,
vite preview, and node dist/server/node.js as behind a Node entry
written like the above. For the client's IP on bare Node, read
event.nativeEvent.socket.remoteAddress; behind a proxy or load balancer
that address is the proxy's, so read the forwarding headers off
event.request instead (x-forwarded-for and friends) — only when you
trust the proxy that set them.
- Preview:
vite build && vite previewruns the production artifact with no server file — Vite's preview statics servedist/client, and everything else (pages, the server-function endpoint, middleware) dispatches through the built handler.
Each request is scoped with provideRequestEvent, so getRequestEvent()
works during the render; hashed client assets (entry script, CSS) are
resolved through the build manifest and injected into <head>.
Every dispatch runs under a stub-backed request event
(createRequestEvent from @solidjs/web), and page responses go through
the runtime's response-head lifecycle (createSSRResponse):
httpStatus() / httpHeader() writes made during the render land on the
wire at shell flush, a Location header set before the flush becomes a
real 3xx redirect, and one set after it (streamed responses) falls back to
a <script>window.location=...</script> tail.
middleware points at a server-only module default-exporting one
fetch-style middleware — (request, next) => Response | Promise<Response>
— or an array of them, composed in order:
// vite.config.ts
solid({ start: { middleware: './src/middleware.ts' }, ssr: true });
// src/middleware.ts
import { getRequestEvent } from '@solidjs/web';
export default async function auth(request: Request, next) {
getRequestEvent().locals.user = await userFromCookie(request);
try {
const response = await next();
response.headers.set('server-timing', 'app'); // pre-wire window
return response;
} catch (error) {
return new Response('oops', { status: 500 });
}
}The chain fronts every request the plugin dispatches — page SSR and the
server-function endpoint, dev, production, and preview alike — and runs
inside the request-event scope, so getRequestEvent() works exactly as in
application code (the endpoint shares the chain's event, so locals
decoration is visible to server functions too). Nothing reaches the wire
until the outermost middleware returns: headers stay mutable after
next() even for streamed responses.
setup points at a server-only module default-exporting a per-request
app-setup hook: (event, App) => Component | void | Promise<Component |
void>. The generated server entry awaits it after the middleware chain has
dispatched to the page render and immediately before renderToStream — the
seam for routers that must prepare an app instance per request before SSR
can begin (create a router bound to the request, await router.load(),
then render):
// vite.config.ts
solid({ start: { setup: './src/setup.tsx' }, ssr: true });
// src/setup.tsx
import type { Component } from 'solid-js';
import type { RequestEvent } from '@solidjs/web';
export default async function setup(event: RequestEvent, App: Component) {
const router = createRouter({ url: event.request.url });
await router.load(); // async work completes before the shell streams
return () => <App router={router} />; // rendered in the app's place
}event is the shared request event — the same one the middleware chain
decorated, so locals are visible — and the hook runs inside the request
scope (getRequestEvent() answers in anything it calls). Return a
component and the generated entry renders it inside the Document where
<App /> would have been; return nothing and <App /> renders unchanged,
so a pure side-effect setup (seeding a per-request cache) needs no return.
Zero-config apps are untouched: without the option the generated entry is
byte-identical to before.
Two boundaries to know: the hook is a page-render seam — the middleware
chain and the server-function endpoint run without it — and it only exists
in generated entries (an authored entry-server owns render() already;
configuring both is an error). And as with any server-side tree shaping,
whatever the hook renders must be matched client-side for hydration —
routers that own both sides (their client entry re-creates the router and
hydrates the same tree) fit naturally.
instrument — a server-only module that runs to completion before
anything else in the server graph loads: the app, the middleware,
@solidjs/web, every dependency. The seam for instrumentation that must
patch the runtime before the modules it patches are loaded — an APM's
OpenTelemetry setup, a profiler, a module.register hook:
// vite.config.ts
solid({ start: { instrument: './src/instrument.ts' }, ssr: true });
// src/instrument.ts
import * as Sentry from '@sentry/node';
Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 1 });Import order alone cannot do this in ESM: static imports are hoisted and
evaluated in dependency order, so import './instrument' at the top of an
entry still runs after everything the entry imports. The plugin therefore
hands out the handler as await import(instrument); await import(handler)
— top-level await sequencing, the one construct that guarantees the order —
on every surface (vite dev, vite build, vite preview, a host consuming
the handler entry). This replaces the per-host node --import
instrument.mjs dance. The module may be async and needs no exports; the
server build must keep code splitting on (the default), since inlining
dynamic imports would hoist the handler graph back above the instrument.
renderMode — how a page render becomes a response body: 'stream'
(the default) or 'async', or a module path deciding per request.
Streaming flushes the document shell as soon as it is ready, with every
<Loading> fallback in place, and streams the boundaries' content behind it
in later chunks; inline scripts swap that content into the page as it
arrives. That is the best time-to-first-byte a server render can have, but a
client that never runs JavaScript — a crawler, curl, a browser with
scripts disabled — is left looking at the fallbacks forever
(solidjs/solid#3280).
'async' is the other end of that trade: the handler awaits the render until
every boundary has settled and sends one complete document.
solid({ start: { renderMode: 'async' }, ssr: true });Because nothing has flushed when a boundary resolves, its content is spliced
in place of its placeholder — the document carries no fallback markup, no
swap templates, no swap scripts — while hydration data still serializes
exactly as before, so JavaScript clients hydrate the settled document the
same way they hydrate a streamed one. The tradeoffs are inherent: the
response waits for the slowest boundary before its first byte, and the whole
page buffers in memory before it goes out. Two consequences worth knowing:
deferStream is moot under 'async' (everything defers), and a Location
header written mid-render — the post-flush script redirect in stream mode —
becomes a real 3xx with no body, which is exactly what a no-JS client needs.
Most apps want streaming for browsers and a complete document for the few
clients that cannot run the swap. The per-request form is a module path
(relative to the Vite root, following the middleware/setup convention
— a Vite config cannot serialize a closure into the generated handler)
default-exporting (event) => 'stream' | 'async' | Promise<'stream' |
'async'>. It runs inside the request scope after the middleware chain, so
event.locals is decorated by the time it decides:
// vite.config.ts
solid({ start: { renderMode: './src/render-mode.ts' }, ssr: true });
// src/render-mode.ts
import type { RequestEvent } from '@solidjs/web';
const CRAWLER = /Googlebot|bingbot|DuckDuckBot|Slurp|Baiduspider|YandexBot/i;
export default function renderMode(event: RequestEvent) {
const { request } = event;
if (new URL(request.url).searchParams.has('nojs')) return 'async';
if (CRAWLER.test(request.headers.get('user-agent') ?? '')) return 'async';
return 'stream';
}Hosts driving the handler directly can decide per call instead:
handleRequest(request, { renderMode: 'async' }). Precedence is that
runtime option, then the module function's result, then the static config;
an unknown value from any of the three is an error naming its source. The
mode applies to generated and authored entries alike — an authored
render() returning a renderToStream result is awaited the same way (and
in production its client-entry reference is still rewritten). httpStatus() /
httpHeader() declarations survive either mode: the runtime freezes the
response head when the awaited render completes (@solidjs/web 2.0.0-rc.7+),
just as streaming freezes it at shell flush. Server mode only — in client mode the served shell has no boundaries to
settle, so the option is a documented no-op there.
env — first-party typed environment variables. A schema file at the
project root — env.ts (or env.js), probed automatically; point
elsewhere with start: { env: './path' }, disable with env: false —
default-exports server and client maps of
Standard Schema validators (zod, valibot,
arktype — even mixed per key; nothing is imported from the plugin):
// env.ts
import { z } from 'zod';
export default {
server: {
DATABASE_URL: z.url(),
SESSION_SECRET: z.string().min(32),
},
client: {
VITE_APP_NAME: z.string().min(1),
},
};The validated values come back through two fully typed virtual modules:
// server-only modules (middleware, "use server" modules, the server entry)
import { env } from 'virtual:env/server'; // every var
// anywhere
import { env } from 'virtual:env/client'; // the VITE_-prefixed client vars- Validation is node-only and layered. The plugin loads the
.env*files through Vite'sloadEnv(withprocess.envwinning, so CI secrets take precedence), folds them intoprocess.envitself — noloadEnvone-liner in vite.config, and server code readingprocess.envdirectly sees the file-loaded vars too — and validates before anything builds. In dev every failure renders the error overlay with the per-key report, and.env*/schema edits revalidate live. In a build,clientfailures fail the build (those values are baked);serverfailures only warn — a build machine may legitimately not have the production secrets — and boot validation enforces them. - Client values are baked, server values are runtime. That's what the
public
VITE_prefix means:virtual:env/clientis the validated output serialized as plain JSON (defaults applied, coercions done) with zero schema-library bytes.virtual:env/serveris not baked — it readsprocess.envwhen the server boots and validates through your own schema (imported into the server bundle, where shipping the validator is fine). Platform-injected vars that don't exist at build time work, secrets rotate without a rebuild, and no secret value exists in any dist artifact; an invalid server environment fails boot with the same per-key report. Boot validation is synchronous: the generated server env module contains no top-level await, so the server bundle works under any downstream build target (Nitro's node-server preset, es2020 — noesnextoverride needed). The flip side:servervalidators must be synchronous — an async refinement/transform on a server key is rejected at config time with the fix in the message (clientkeys may stay async; they are awaited at build time where the values are baked). - Leaks are errors. Importing
virtual:env/serverfrom a client module graph is a hard error naming the importer (the app root and everything it imports hydrate — they are client code; keep server env in middleware,"use server"modules, or an authored server entry). Client keys must carry the public prefix (VITE_, or yourenvPrefix) — enforced at config time. And a client-build scan fails the build when a server var's literal value shows up quoted in a client chunk. - Types are generated by inference. A
solid-env.d.tsis written next to the schema file (keep both inside your tsconfiginclude): it derives each var's type from your own schema through the Standard Schema output type, soenv.VITE_APP_NAMEis whatever your validator outputs — with any compliant library and no per-library plumbing.
Env works identically in both start modes (a client-mode static build
carries only the client vars); it is a start-mode feature, so without start
there is no env layer. See examples/start-env for the full story,
including the failure modes.
Design credit: the shape of this feature — the schema-file convention,
the virtual:env/* module names (kept identical on purpose), baked
client values, the leak scan — follows
@vite-env/core (MIT), the
design-correct prior art, reimplemented on this plugin's machinery with
Standard Schema as the only contract (and runtime-read server values).
errorBoundary — in a production build, generated entries wrap the app
in a default error boundary (and the document in an outer one): a render
error streams a generic 500 | Internal Server Error fallback — no stack
or error details reach the HTML; the error itself goes to console.error
— and an error caught before the shell flushes commits a real 500 status
through the response-head lifecycle. Development is unaffected (Vite's
error overlay owns dev errors), as are authored entries — the boundary is
generated-entry codegen. Disable it with start: { errorBoundary: false }
when application middleware owns error handling (an error middleware only
sees the throw when no boundary catches it first). Default: true.
css.filter — include/exclude patterns
(picomatch globs or regexes;
relative globs resolve against the Vite root) for the module graphs the dev
server crawls when collecting the CSS it inlines into <head> (the no-FOUC
guarantee). By default the crawl covers the app's own sources and skips
node_modules. exclude prunes matching graphs — providing one replaces
the default node_modules exclusion — and include opts matching files in
on top of that baseline, which is how a dependency's CSS gets
server-inlined in dev:
solid({
start: {
css: { filter: { include: /node_modules\/some-ui-lib/ } },
},
ssr: true,
});CSS files themselves and virtual modules always pass — the filter decides
which module graphs are traversed, not which stylesheets are kept — and a
file matching both patterns stays excluded (Vite createFilter's
conflict rule). Development only: excluding a graph does not remove its
CSS from the production build, where CSS always comes from the built
assets.
Entry resolution (all paths relative to the Vite root):
- Explicit
start.entryServer/start.entryClientoptions. - Conventional files:
src/entry-server.{tsx,jsx,ts,js,mjs,tsrx}andsrc/entry-client.{tsx,jsx,ts,js,mjs,tsrx}. Entry files come in pairs — providing only one is an error. The server entry must exportrender(request?, context?)returning arenderToStreamresult, an HTML string, or aResponse;context.clientEntrycarries the resolved client entry URL, and in production any literal"/src/entry-client.tsx"reference in the rendered HTML is rewritten to the hashed asset (the classic harness convention keeps working). - Generated entries (the zero-config path): when no entry files exist, both
are generated from a root component —
start.app, defaulting tosrc/App.{tsx,jsx,ts,js,tsrx}(or lowercasesrc/app.*) — wrapped in a document shell:start.document, defaulting tosrc/Document.{tsx,jsx,tsrx}, else a built-in minimal shell. A custom document receives the app asprops.childrenand must render the full<html>document including<HydrationScript />; the client entry script is injected into<head>automatically.
With serverFunctions also enabled the two
compose: handleRequest serves the endpoint on every surface (in dev the
server-function middleware pre-loads the referenced module, then dispatches
through the same handler), so one middleware chain and one request event
front pages and server functions identically.
The normal ssr environment exposes the default Fetchable handler as its
index service entry in development and production. Provider Vite plugins
can adopt that environment directly: they supply its runtime and build
orchestration while Solid continues to supply the application entry,
manifest, middleware, and server-function dispatch. When a provider replaces
the development environment with a non-runnable one, Solid detects that
ownership and stands its HTTP middlewares down automatically.
Two explicit switches remain for custom host setups:
start.external: true— hands the whole server side to a host that does not adopt Solid's normalssrenvironment. Solid skips its server-build wiring and stands its development middlewares down, while continuing to provide the generated entries, client manifest, andvirtual:solid-ssr-handler. This is mainly for differently named or independently configured environments.serverFunctions.devMiddleware: false— the narrow, endpoint-only switch: keeps start mode's server build and SSR serving, hands only server-function dispatch in dev to the host. For setups withoutstart, or when only the endpoint should move.
virtual:solid-manifest exposes the client asset manifest that serving
works from — a server-side module, available in dev and in SSR builds. In
an SSR build its default export is the parsed client manifest
(dist/client/.vite/manifest.json), keyed by source path with the resolved
Vite base attached as _base; in dev it exports the live asset resolver
the plugin uses for dev CSS collection instead of a static object. This is
the seam for frameworks and routers that do their own asset gating —
deciding which scripts and styles a response carries, as the TanStack Start
integration does — without re-reading the manifest from disk or re-deriving
base. Ambient types ship with the plugin
(/// <reference types="@solidjs/vite-plugin/virtual-solid-manifest" />);
see that .d.ts and the exported ViteManifest type for the full shape.
Without ssr: true — client mode (experimental), the same conventions
with client-only rendering:
export default defineConfig({
plugins: [solidPlugin({ start: true })],
});- Dev: every HTML-accepting GET streams the rendered document shell —
without the app, which never renders on the server — with the entry
graph's CSS inlined; deep links get the same shell (history-fallback
semantics). The generated client entry
render()s (not hydrates) the app intodocument.body. - Build:
vite buildemits a purely staticdist/client— the shell is prerendered once through the built handler intodist/client/index.html, with the hashed entry script and the entry graph's CSS links — deployable to any static host. No server bundle remains unlessserverFunctionsis enabled, in which casedist/serveris kept and itshandleRequestserves the endpoint (pages stay static). - Transforms: client code compiles exactly like a plain SPA today
(
generate: 'dom', non-hydratable); only the document shell goes through the SSR transforms. vite previewserves the static build with history fallback (and dispatches the server-function endpoint through the kept handler).- Server-only options are inert here rather than errors, so a config
survives the flip untouched:
start.entryServer(and conventionalsrc/entry-server.*files) are ignored — the shell render is always generated — and so isstart.external. An authoredsrc/entry-client.*stands alone and owns the mount.
The point is the migration story: an app born with start: true moves to
server rendering by setting ssr: true — same App, same Document,
same routes, same server functions; the plugin swaps render for hydrate,
turns the hydratable transforms on, and ships the server bundle. (A
Document authored for SSR carries <HydrationScript />; in client mode
the plugin strips its script from the served shell — nothing hydrates, so
a shared Document costs nothing — and the built-in shell omits it.)
Start-mode serving is opt-in via start, so bare ssr: true setups keep the
transform-only behavior. See examples/start-ssr for a complete SSR app
(including a one-file production server and server functions),
examples/start-client for client mode (whose test flips the same app
between the modes), and examples/ssr for the manual ssr: true wiring.
options.serverFunctions
- Type: Boolean | Object
- Default: undefined
Enables "use server" server function compilation (experimental). Pass
true for the defaults (runtime from @solidjs/web/server-functions,
endpoint /_server) or an options object (runtime, endpoint, filter,
directive, manifest, devMiddleware, configure) to customize.
The setup is zero-config: in dev a middleware on the Vite server handles the
endpoint end to end — no server-function code needed in your server entry.
For production SSR builds, either use SSR start mode (start
with ssr: true, whose handler serves the endpoint automatically) or
import virtual:solid-server-function-handler in your server entry and
mount its handleServerFunctionRequest(request) export on the endpoint.
devMiddleware: false hands endpoint dispatch in dev to a host instead
of the plugin's middleware. The middleware executes functions in Vite's
node-side SSR environment; when another plugin's server environment should
run them — e.g. @cloudflare/vite-plugin, so functions see workerd bindings
(env/ctx) in dev exactly like production — turn it off and let the host
dispatch: it loads virtual:solid-server-function-handler through its own
environment and calls handleServerFunctionRequest(request), the same
contract as production. Compilation and the virtual modules keep working;
endpoint requests simply fall through to the host. Since the middleware's
on-demand module loading is off too, a host owning dev dispatch should
side-effect import virtual:solid-server-function-manifest in its server
entry so functions referenced only by client code still register. (When a
provider owns the ssr environment outright — it isn't runnable — the
middleware already stands down automatically; see the external option
under start for the whole-server switch and how the
three options relate.)
configure: './src/server-config.ts' pins a server-only module (path
resolved against the Vite root) into the handler graph: the generated
virtual:solid-server-function-handler module side-effect imports it before
dispatching anything. It's the guaranteed pre-dispatch home for server-side
runtime registration — e.g. a router's single-flight collector:
// src/server-config.ts
import { configureServerFunctionsServer } from '@solidjs/web/server-functions/server';
configureServerFunctionsServer({ collectFlightData: createFlightDataCollector(router) });Registration living in the app graph only loads with the first page render, so after a dev-server restart the first mutation can race it; the handler graph loads before the first dispatch on every surface (dev middleware and production handler alike), and edits to the module hot-invalidate the handler in dev. Config calls merge per key, so it composes with the plugin's own runtime configuration.
Server-function requests are same-origin protected by @solidjs/web. To
allow another trusted origin, configure it in the same server-only module:
import { configureServerFunctionsServer } from '@solidjs/web/server-functions/server';
configureServerFunctionsServer({
csrf: { origin: ['https://app.example.com'] },
});Set csrf: false only when another trusted layer protects the endpoint.
Meta-frameworks that need to control plugin ordering and dispatch requests
through their own server should use the standalone serverFunctions()
export instead, which never installs the dev middleware. See
examples/start-ssr for a complete app.
Server components (experimental): serverFunctions: { components: true }
lets a "use server" function return a component. Server components ride
server functions — same endpoint, same compilation — with zero extra plugin
config: responses for component-returning functions are served as streamed
HTML that the client applies in place (client state and DOM identity inside
survive updates), and the plugin's dev middleware and production handler
handle that automatically. Combined with SSR start mode
(start with ssr: true) and generated entries, the
document wiring is emitted too: server components render inline in the
SSR'd document and are adopted
at boot with zero endpoint requests. With authored entries, the app-side
pieces (the render plugin and the client's installServerComponents()
call, both from @solidjs/web/frames) live in your entry files instead.
See examples/start-ssr for a complete page.
Composing hosts (e.g. the Astro adapter or TanStack Start's Solid
integration) that emit that document wiring themselves — the render plugin
around their renders plus a client-side installServerComponents() call —
should set components: 'external' instead of true. It behaves
identically (all the same transforms and codegen), and declares the host
owns the wiring, so the plugin skips the warning it otherwise prints when
the option is enabled without SSR start mode. It reuses the plugin's
external vocabulary (cf. start.external — a host owns the server).
options.compiler
- Type:
"babel" | "native" - Default:
"native"
Choose the JSX compiler backend. The default "native" compiles JSX through
the native compiler from @solidjs/compiler. "babel" runs
@solidjs/babel-plugin instead and only switches the JSX transform — every
other pass (the lazy() module-URL transform and the solid-refresh HMR
transform) is native in both modes.
"babel" is the escape hatch: if the native output ever differs from what
you expect, set compiler: 'babel' and file an issue — the behavioral diff
between the two modes is the bug report. Platforms without a prebuilt native
binary (for example StackBlitz WebContainers) automatically fall back to the
@solidjs/compiler-wasm32-wasi build, so no configuration is needed
there.
import { defineConfig } from 'vite';
import solidPlugin from '@solidjs/vite-plugin';
export default defineConfig({
plugins: [solidPlugin({ compiler: 'babel' })],
});Experimental TSRX
Files ending in .tsrx are recognized automatically as TypeScript TSRX; they
do not need to be listed in options.extensions. Both the native and Babel
compiler backends preserve the .tsrx filename when invoking their TSRX
frontends.
Scoped CSS emitted by either backend is exposed as a sibling virtual CSS sidecar and imported once from the compiled module. The sidecar goes through Vite's normal CSS pipeline, so extraction and injection work in development, production builds, and SSR, including client HMR and SSR development style collection. A file that emits no CSS has no sidecar import.
The Babel backend chains TSRX source maps through the later lazy-module and
refresh transforms. The native compiler does not currently emit the required
TSRX projection map, so native .tsrx transforms return no source map. With
compiler: "native" and custom babel options, the custom Babel support pass
runs after native TSRX lowering (on ordinary JavaScript); ordinary JSX/TSX
keeps the existing pre-native ordering.
With serverFunctions enabled, function-level "use server" directives work
in .tsrx with both compiler backends. The plugin lowers TSRX first, then runs
the same native directive transform while retaining the authored .tsrx path
for stable client/server function IDs. TSRX's host-defined
module server { ... } profile is not supported.
options.babel
- Type: Babel.TransformOptions
- Default: {}
Pass any additional babel transform options. Those will be merged with the transformations required by Solid.
With the native compiler these options normally run before JSX lowering; for
.tsrx only, they run after native TSRX lowering as described above.
options.solid
- Type: @solidjs/compiler / @solidjs/babel-plugin
- Default: {}
Pass additional Solid JSX compiler options. Both backends carry the Solid
defaults (moduleName: "@solidjs/web", the control-flow built-ins,
custom-element context, and conditional wrapping) internally; anything set
here is merged over them and applied to whichever backend is selected.
options.solid.sourceNames
- Type:
boolean | { components?: boolean; bindings?: boolean; primitives?: boolean } - Default: on in dev and under
observe, off in production builds
Which names as written in source are carried into output so the dev and observe runtimes
can label the reactive graph after minification — in diagnostics, whyDidRun chains, error
owner paths, and the Chrome performance tracks. Three kinds:
componentsemits the tag name (createComponent(Home, props, "Home")→ owners read<Home>).bindingsnames compiled binding effects by what they write (span.textContent,div.class:active, a holediv.children).primitivesnamescreateSignal/createMemo/createStore/… after the identifier they are declared as (count,doubled,todos.title), prefixed with the enclosing non-component function for composed primitives (createCounter.value). An explicitnameoption is never overridden.
The kinds come from two different places. components and bindings are the JSX compiler's
own sourceNames option, passed through to whichever backend compiles your JSX
(@solidjs/compiler or @solidjs/babel-plugin). primitives is the native compiler's
standalone transformSourceNames pass — plain JavaScript in and out — which the plugin runs
on every module it sees, .ts/.js files included, never inside node_modules. The plugin
always runs its non-JSX work through the native compiler, so babel apps get primitive names
too.
The default follows the posture: on whenever the plugin compiles for the dev runtime (the
same dev flag it hands the compilers — vite dev, or dev: true) and for observe
builds; off for production builds, whose runtime ignores the names anyway, so production
output is unchanged. sourceNames: false turns every kind off, in dev too; true turns every
kind on, production builds included. The object form sets the listed kinds and leaves the
rest at the posture default — { primitives: false } keeps component and binding names in
dev but skips the primitives pass.
options.typescript
- Type: @babel/preset-typescript
- Default: {}
Pass any additional @babel/preset-typescript.
options.extensions
- Type: (string, [string, { typescript: boolean }])[]
- Default: []
An array of custom extension that will be passed through the solid compiler.
By default, the plugin transforms jsx, tsx, and experimental tsrx files.
TSRX is always recognized and does not need to be added here.
This is useful if you want to transform mdx files for example.
server-only and client-only boundary markers
The plugin always claims the bare specifiers server-only and client-only
as marker modules. Import one to pin a module to an environment:
import 'server-only'; // this module must never be bundled for the client
export const dbClient = createDbClient(process.env.DATABASE_URL);Importing server-only from a module that ends up in a client bundle fails
the build with an error naming the importer (and vice versa for
client-only); in the allowed environment the marker resolves to an empty
module. This turns "server code silently shipped to the browser and crashed
at runtime" into a build-time error at the exact import edge.
For TypeScript, the ambient declarations ship with the plugin — add to an
env.d.ts:
/// <reference types="@solidjs/vite-plugin/boundary-modules" />Note: these markers shadow React's server-only / client-only npm
packages if they happen to be installed; the semantics are the same, and the
plugin's errors are prefixed [@solidjs/vite-plugin].
Note on HMR
Starting from version 1.1.0, this plugin handles automatic HMR. The refresh
transform is compiled natively by @solidjs/compiler and drives the
dev-only solid-js/refresh runtime entry that ships with Solid (the
standalone solid-refresh package
is no longer used).
At this stage it's still early work but provide basic HMR. In order to get the best out of it there are couple of things to keep in mind:
When you modify a file every state below this component will be reset to default state (including the current file). The state in parent component is preserved.
The entrypoint can't benefit from HMR yet and will force a hard reload of the entire app. This is still really fast thanks to browser caching.
If at least one of this point is blocking to you, you can revert to the old behavior by opting out the automatic HMR and placing the following snippet in your entry point:
const dispose = render(() => <App />, document.body);
if (import.meta.hot) {
import.meta.hot.accept();
import.meta.hot.dispose(dispose);
}Troubleshooting
It appears that Webstorm generate some weird triggers when saving a file. In order to prevent that you can follow this thread and disable the "Safe Write" option in "Settings | Appearance & Behavior | System Settings".
If one of your dependency spit out React code instead of Solid that means that they don't expose JSX properly. To get around it, you might want to manually exclude it from the dependencies optimization
If you are trying to make directives work, and they somehow don't try setting the
options.typescript.onlyRemoveTypeImportsoption totrue
Migration from v1
The master branch now target vite 2.
The main breaking change from previous version is that the package has been renamed from @amoutonbrady/vite-plugin-solid to vite-plugin-solid (since renamed again to @solidjs/vite-plugin — see the note at the top).
For other breaking changes, check the migration guide of vite.
Testing
If you are using vitest, this plugin already injects the necessary configuration for you. It even automatically detects if you have @testing-library/jest-dom installed in your project and automatically adds it to the setupFiles. All you need to add (if you want) is globals, coverage, and other testing configuration of your choice. If you can live without those, enjoy using vitest without the need to configure it yourself.
Tests default to the client posture, regardless of the app's ssr flag: DOM codegen, non-hydratable (nothing hydrates in a test), browser export conditions, and a jsdom default test.environment. A server-rendered app needs no ssr: mode !== 'test' workaround — DOM component tests just work.
Server-runtime unit tests (server functions, sessions, renderToString — anything that needs isServer to be true and the real server build of the framework) opt out per vitest project by setting test.environment: 'node' (or 'edge-runtime') explicitly. That project gets the server posture end to end: server export conditions, ssr codegen, and the framework inlined so the whole graph — request-event storage included — resolves into one server-build instance. No inline/alias configuration needed. Both postures coexist in one workspace:
// vite.config.ts
test: {
projects: [
{
extends: true,
test: { name: 'client', environment: 'jsdom', include: ['src/**/*.test.tsx'] },
},
{
extends: true,
test: { name: 'server', environment: 'node', include: ['src/server/**/*.test.ts'] },
},
],
},The posture is chosen while each project resolves its own Vite config. Vitest 5 lets inline projects reuse the root Vite server instead (test.sharedViteServer, on by default), which skips that step, so the plugin sets sharedViteServer: false when the root config declares test.projects — trading Vitest 5's shared-server optimization for the per-project resolution Vitest 4 always did. Setting the option yourself overrides this; with true, every shared project runs with the root config's posture, without the injected jest-dom setup file, and without the plugin's server.deps handling (the framework inlined for the server posture, externalized for the client one).
