Skip to content

Simple RSC With Vinxi

Posted on:March 2, 2024

Today we will look at how to build a simple React server components setup using Vinxi.

Initial Setup

First of all create a new folder called vinxi-server-components and create the following package.json in it. This is a pretty standard Vinxi setup but the important part is the experimental React packages which has the latest server component features. They will become stable in a React 19 release.

// package.json
{
  "name": "vinxi-server-components",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "scripts": {
    "dev": "vinxi dev",
    "build": "vinxi build",
    "start": "vinxi start"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@vinxi/react-server-dom": "^0.0.3",
    "@vinxi/server-components": "^0.3.0",
    "react": "0.0.0-experimental-035a41c4e-20230704",
    "react-dom": "0.0.0-experimental-035a41c4e-20230704",
    "vinxi": "^0.3.4"
  }
}

After this make sure to run pnpm i to install the dependencies.

Next we will need to setup an app.config.js that configures our different routers.

// app.config.js
import { createApp } from "vinxi";

export default createApp({
  routers: [
    {
      name: "public",
      type: "static",
      dir: "./public",
    },
    {
      name: "ssr",
      type: "http",
      handler: "./src/entry-server.jsx",
      target: "server",
    },
    {
      name: "client",
      type: "client",
      handler: "./src/entry-client.jsx",
      target: "browser",
      base: "/_build",
    },
  ],
});

You can read my building a metaframework with vinxi article if you need more background on how the above works.

Now create a simple entry-server.jsx in a src folder. (this is pretty much identical to the ssr example in the article above)

// src/entry-server.jsx
import { eventHandler } from "vinxi/http";
import MyApp from "./MyApp";
import { getManifest } from "vinxi/manifest";
import { renderToPipeableStream } from "react-dom/server";
import { routes } from "./router";
import React from "react";

export default eventHandler(async event => {
  const clientManifest = getManifest("client");

  const clientHandler = clientManifest.inputs[clientManifest.handler];
  const scriptSrc = clientHandler.output.path;

  const page = routes[event.path];

  const stream = await new Promise(async resolve => {
    const stream = renderToPipeableStream(<MyApp>{page}</MyApp>, {
      onShellReady() {
        resolve(stream);
      },
      bootstrapModules: [scriptSrc],
      bootstrapScriptContent: `window.manifest = ${JSON.stringify(
        await clientManifest.json()
      )};`,
    });
  });

  event.node.res.setHeader("Content-Type", "text/html");
  return stream;
});

The two main things we need here are a routes variable to look up what to render within the MyApp children, and then a MyApp component itself. We are doing very simplistic manual routing for now.

// router.jsx
import React from "react";
import About from "./About";
import Index from "./Index";

export const routes = {
  "/": <Index />,
  "/about": <About />,
};

To bring it all together we need a MyApp component which renders an html document with the children wrapped in Suspense.

// MyApp.jsx
import React, { Suspense } from "react";

export default function MyApp({ children }) {
  return (
    <html>
      <head>
        <title>Test</title>
      </head>
      <body>
        <a href="/">Go home</a> | <a href="/about">Go to about</a>
        <Suspense>{children}</Suspense>
      </body>
    </html>
  );
}

Within <Index /> and <About /> you can play around with server component features like using await for data fetching, and 'use client' for client specific features.

A example of components to include is something like:

// Todos.jsx
export default async function Todos() {
  const todos = await getTodos();
  return (
    <>
      <h2>Todos</h2>
      {todos.map(todo => (
        <li>{todo.title}</li>
      ))}
    </>
  );
}

// Counter.jsx
'use client';

import { useState } from "react"

export default function Counter() {
    const [count, setCount] = useState(0);
    return <div>
        {count}
        <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
}

The last part of the initial setup is adding an entry-client.jsx:

// entry-client.jsx
import { createRoot } from "react-dom/client";
import "vinxi/client";
import React, { Suspense } from "react";
import MyApp from "./MyApp";
import { routes } from "./router";

function App({ url }) {
  const page = routes[url];
  return (
    <Suspense>
      <MyApp>{page}</MyApp>
    </Suspense>
  );
}

const root = createRoot(document);
root.render(<App url={window.location.pathname} />);

Now run pnpm dev and see your pages rendered as expected. Clicking the links navigate to the correct page as well but does a full page refresh. We will fix that in a bit!

RSC Setup

Up till now we haven’t really done anything specific to RSC. We have just setup the surrounding puzzle pieces using SSR.

Let’s add an /_rsc/ endpoint which will render out our pages to RSC output our frontend expects.

// app.config.js
import { createApp } from "vinxi";
import { serverComponents } from "@vinxi/server-components/plugin";

export default createApp({
  routers: [
    // other routers
    {
      name: "rsc",
      worker: true,
      type: "http",
      base: "/_rsc",
      handler: "./src/rsc.jsx",
      target: "server",
      plugins: () => [serverComponents.server()],
    },
    // other routers
  ],
});

We also need to add a plugin to the client router while we are here:

{
      name: "client",
      type: "client",
      handler: "./src/entry-client.jsx",
      target: "browser",
      base: "/_build",
      plugins: () => [serverComponents.client()],
    }

The handler for the rsc router is actually really short and is as follows:

// rsc.jsx
import { eventHandler } from "vinxi/http";
import { renderToPipeableStream } from "@vinxi/react-server-dom/server";
import MyApp from "./MyApp";
import { routes } from "./router";
import React from "react";

export default eventHandler(async event => {
  const page = routes[event.path];

  return renderToPipeableStream(<MyApp>{page}</MyApp>);
});

For interest sake restart the dev server and then visit http://localhost:3000/_rsc/ in your browser to see what is returned. It’s actually not JSON but just text!

1:"$Sreact.suspense"
0:["$","html",null,{"children":[["$","head",null,{"children":["$","title",null,{"children":"Test"}]}],["$","body",null,{"children":[["$","a",null,{"href":"/","children":"Go home"}]," | ",["$","a",null,{"href":"/about","children":"Go to about"}],["$","$1",null,{"children":["$","div",null,{"children":["$","h1",null,{"children":"Home"}]}]}]]}]]}]

It’s just providing the frontend with instructions on how to recreate the markup. How do we actually use this information? We use createFromFetch which reconstructs a component out of our fetch call response.

// entry-client.jsx
import { createRoot } from "react-dom/client";
import "vinxi/client";
import { createFromFetch } from "@vinxi/react-server-dom/client";
import React, { Suspense } from "react";

function App({ url }) {
  return <Suspense>{createFromFetch(fetch("/_rsc" + url))}</Suspense>;
}

const root = createRoot(document);
root.render(<App url={window.location.pathname} />);

If you reload your page you will see it actually fetches from the RSC endpoint to decide what to render:

RSC Output

One thing that will blow up is any "use client" directive. To fix this we have to add this to entry-client.jsx

import { createModuleLoader } from "@vinxi/react-server-dom/runtime";
import { getManifest } from "vinxi/manifest";

globalThis.__vite__ = createModuleLoader({
  loadModule: async id => {
    return getManifest("client").chunks[id].import();
  },
});

This tells Vite where to load our chunks from the client manifest.

One last thing to take care of. When we click on a link we don’t want to do a full page refresh. We want to intercept the click, make a request to our _rsc endpoint and render that page. We do this by intercepting clicks as follows:

// entry-client.jsx
window.addEventListener("click", e => {
  if (e.target?.tagName !== "A") {
    return;
  }

  e.preventDefault();

  window.history.pushState(null, "", e.target.pathname);

  root.render(<App url={e.target.pathname} />);
});

Now clicking the “Go home” and “Go to about” make calls to the endpoint and rerender appropriately.

Source Code

View source code

Additional Resources

I hope you have learnt a bit about how React server components work. If you want to learn more about the simplified mechanics I highly suggest watching these two videos. I used them for inspiration for this blog post.