/ssrx

A thin layer on top of Vite to build modern SSR apps with a delightful DX.

Primary LanguageTypeScriptMIT LicenseMIT

🚀 Welcome to SSRx

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:

  1. A Vite plugin to improve the DX of developing SSR apps (can be used on it's own).
  2. 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.

@ssrx/vite

❗ 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.

Usage

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:

Requirement 1 - a client entry file

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 />);

Requirement 2 - a server entry file

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 as Hono and h3, as well as alternative runtimes such as bun, 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',
      },
    });
  },
};

Requirement 3 - a routes file

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.

Finally, update your vite.config.js

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.

Runtimes

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.

@ssrx/renderer

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.

Directory

Package Release Notes
@ssrx/renderer @ssrx/renderer version
@ssrx/react @ssrx/react version
@ssrx/remix @ssrx/remix version
@ssrx/solid @ssrx/solid version
@ssrx/trpc-react-query @ssrx/trpc-react-query version
@ssrx/plugin-react-router @ssrx/plugin-react-router version
@ssrx/plugin-solid-router @ssrx/plugin-solid-router version
@ssrx/plugin-tanstack-query @ssrx/plugin-tanstack-query version
@ssrx/plugin-tanstack-router @ssrx/plugin-tanstack-router version
@ssrx/plugin-trpc-react @ssrx/plugin-trpc-react version
@ssrx/plugin-unhead @ssrx/plugin-unhead version

Usage

@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

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;
  };
};

Inspiration

Many thanks to these awesome libraries! Please check them out - they provided inspiration as I navigated my first Vite plugin.