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