SSRx provides the missing pieces required to create SSR apps with Vite and your third party libraries of choice. It is framework agnostic on the client and the server - use React, Solid, Hono, H3, Cloudflare, Bun, you name it.
SSRx is split into two parts that can be used independently, or together:
- A Vite plugin to improve the DX of developing SSR apps (can be used on it's own).
- A "renderer" that establishes some patterns to hook into the lifecycle of streaming SSR apps in a framework/library agnostic way. A handful of renderer plugins for common libraries are maintained in this repo.
❗ Remix is transitioning to Vite, so for Vite + React projects I now recommend Remix as the best-in-class option.
The SSRx Vite plugin is barebones and (mostly) unopinionated by design. It can be used standalone, see the
bun-react-router
,
react-react-simple
, and
solid-router-simple
examples.
The goal of @ssrx/vite
is to close the small gaps that prevent Vite from being a delightful building block for modern
SSR apps, not to provide solutions for routing, deployment, etc.
It is:
- ✅ Framework agnostic on the client (use react, solid, etc)
- ✅ Framework agnostic on the server (use node 18+, hono, h3, cloudflare, bun, deno, etc)
- ✅ Simple "native" Vite - continue using
vite dev
,vite build
, etc
It enables:
- Route based code-spliting with asset pre-loading
- Typescript + HMR support on the client AND server
- Elimates FOUC css issues during development
- Generates a
ssr-manifest.json
file during build that maps client route urls -> assets - Provides a
assetsForRequest(url: string)
function that returns a list of assets critical to the given request
❗ A small disclaimer... what makes SSRx great is that it doesn't try to do everything. This means SSRx is intended for a specific audience. If you're looking for something quick and easy, SSRx might not be for you. If you are looking to build a modern SSR app with your choice of 3rd party libraries for routing, head management, etc, then SSRx might be right for you.
First, install deps via yarn, npm, etc, along these lines:
yarn add @ssrx/vite
yarn add -D vite@5
@ssrx/vite
is mostly unopinionated, but does require 3 things:
This file should mount your application in the browser. For React it might look something like this:
import { hydrateRoot } from 'react-dom/client';
import { App } from '~/app.tsx';
hydrateRoot(document, <App />);
A server entry who's default export includes a fetch
function that accepts a
Request and returns a
Response object with your rendered or streamed app.
@ssrx/vite
is focused on supporting the WinterCG standard. Modern node frameworks such asHono
andh3
, as well as alternative runtimes such asbun
,deno
,cloudflare
, and more should all work well with this pattern.
For React, it might look something like this:
import { renderToString } from 'react-dom/server';
import { App } from '~/app.tsx';
export default {
fetch(req: Request) {
const html = renderToString(<App />);
return new Response(html, {
headers: {
'Content-Type': 'text/html',
},
});
},
};
Your routes file should export a routes
object. By default @ssrx/vite
expects the routes
object to conform to the
following shape:
type Route = {
// path must adhere to the path-to-regex syntax
path?: string;
children?: Route[];
// If lazy or component.preload point to a dynamic import, that route will be code split
lazy?: () => Promise<any>;
component?: {
preload?: () => Promise<any>;
};
};
react-router
and solid-router
both conform to this shape out of the box. You can provide your own routerAdapter
if
your routes config does not - see adapter-tanstack-router for an example.
Example:
import { ssrx } from '@ssrx/vite/plugin';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
// ... your other plugins
// The plugin, with all of it's defaults.
// You only need to set these options if they deviate from the defaults.
ssrx({
routesFile: 'src/routes.tsx',
clientEntry: 'src/entry.client.tsx',
serverFile: 'src/server.ts',
clientOutDir: 'dist/public',
serverOutDir: 'dist',
runtime: 'node',
routerAdapter: defaultRouterAdapter,
}),
],
});
See bun-react-router
,
react-react-simple
, and
solid-router-simple
for more concrete examples.
The ssrx
vite plugin accepts a runtime
option.
Setting the value to edge
will adjust vite to bundle the server output into a single file, and set resolve conditions
more appropriate for ssr / server rendering in popular edge environments.
Setting the value to cf-pages
will adjust the output to be suitable for deployment to Cloudflare Pages, including
generating sane _routes.json
and _headers
defaults.
The SSRx renderer provides building blocks that make it easier to develop streaming SSR apps. It is client and server framework agnostic, so long as the server runtime supports web streams and AsyncLocalStorage (node 18+, bun, deno, cloudflare, vercel, etc).
See the streaming-kitchen-sink and remix-vite examples for a look at how everything can work together in practice.
@ssrx/renderer
exports a createApp
function that allows you to compose all the pieces necessary to render a SSR
streamed application. For example:
app.tsx
// In this case we're using the `react` renderer, which simply wraps @ssrx/renderer with a react specific stream function
import { createApp } from '@ssrx/react';
export const { clientHandler, serverHandler, ctx } = createApp({
// Usually a router plugin will provide the appRenderer, but you can always provide your own if needed
appRenderer:
({ req }) =>
() =>
<div>My App</div>,
plugins: [
// IF you are using `@ssrx/vite`, this plugin injects js/css assets into your html
// (import { viteRendererPlugin } from '@ssrx/vite/renderer')
// viteRendererPlugin(),
//
// ... your plugins, or 3rd party plugins more on the plugin shape below
],
});
entry.client.tsx
import { hydrateRoot } from 'react-dom/client';
import { clientHandler } from './app.tsx';
async function hydrate() {
const renderApp = await clientHandler();
hydrateRoot(document, renderApp());
}
void hydrate();
server.ts
import { serverHandler } from '~/app.tsx';
export default {
fetch(req: Request) {
const appStream = await serverHandler({ req });
return new Response(appStream);
},
};
With the above steps you get a streaming react app with support for lazy asset preloading. However, plugins are where
@ssrx/renderer
really shines.
Plugins can:
- Hook into the client and server rendering in a standardized way
- Extend a typesafe
ctx
object that is made available on the client and the server, even outside of the rendering tree (for example in router loader functions). This is accomplished via a proxy that is exposed on the window in the client context, and via async local storage on the server.
Plugin Shape
The snippet below has been simplified - see the renderer types file for the full plugin signature.
export type RenderPlugin<C extends Record<string, unknown>, AC extends Record<string, unknown>> = {
id: string;
/**
* Create a context object that will be passed to all of this plugin's hooks.
*/
createCtx?: Function;
hooks?: {
/**
* Extend the app ctx object with additional properties. The app ctx object is made available
* to the end application on the server and the client.
*/
'app:extendCtx'?: Function;
/**
* Wrap the app component with a higher-order component. This is useful for wrapping the app with providers, etc.
*/
'app:wrap'?: Function;
/**
* Render the final inner-most app component. Only one plugin may do this - usually a routing plugin.
*/
'app:render'?: Function;
/**
* Return a string or ReactElement to emit some HTML into the document's head.
*/
'ssr:emitToHead'?: Function;
/**
* Return a string to emit into the SSR stream just before the rendering
* framework (react, solid, etc) emits a chunk of the page.
*/
'ssr:emitBeforeFlush'?: Function;
};
};
Many thanks to these awesome libraries! Please check them out - they provided inspiration as I navigated my first Vite plugin.