
React Server Components Without a Framework: Is It Possible?
React Server Components shipped as a stable part of React 19, yet almost every tutorial about them starts with "create a Next.js app." That raises a fair question. If Server Components are a React feature, why do you seemingly need a framework to use them? Can you add them to your existing Vite app, or run them on a plain Node server?
The short answer is: yes, it's possible, but "without a framework" really means "you become the framework." Server Components aren't a function you call. They're an architecture that spans your server, your bundler, and your client runtime, and React deliberately leaves the glue to tools built on top of it.
This post explains what that glue is. You'll see what an RSC payload actually looks like, render one by hand from a plain Node server, look at how the client decodes it, and learn which parts need bundler support. Then we'll look at the lower-level tools, like Vite's RSC plugin and Parcel, that let you adopt Server Components without a full framework, and when that's a good idea.
What Server Components Actually Are
A Server Component is a component that runs only on the server. It can be async, read from a database or the file system directly, and use secrets, because its code never reaches the browser. What it sends to the client isn't HTML and isn't JavaScript. It's a serialized description of the rendered tree, called the RSC payload.
// A Server Component: async, reads data directly, ships no JS
import { db } from "./db";
import { LikeButton } from "./LikeButton";
export async function PostPage({ id }: { id: string }) {
const post = await db.post.findUnique({ where: { id } });
if (!post) return <p>Not found</p>;
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
);
}
LikeButton is a Client Component. Its file starts with the "use client" directive:
// LikeButton.tsx
"use client";
import { useState } from "react";
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return (
<button type="button" data-post={postId} onClick={() => setLikes((n) => n + 1)}>
{likes} likes
</button>
);
}
When the server renders PostPage, it can't run LikeButton, because that needs the browser. Instead, the payload contains a client reference: a pointer that says "at this spot, render the module called LikeButton from this chunk, with these props." The browser then downloads that chunk, renders it, and stitches everything together.
That one idea, a pointer from server output to a client module, is why Server Components need more than React itself.
The Three Pieces You Need
To run Server Components, something has to provide all of these:
- A server environment using the
react-serverexport condition. React ships a special build of itself for Server Components, where hooks likeuseStatedon't exist. Your server code must resolvereactwith this condition, typically vianode --conditions react-server. - A bundler that understands
"use client"and"use server". When server code imports a"use client"file, the bundler must replace that import with a client reference, put the real component into a browser chunk, and write a manifest mapping references to chunks. - A client runtime that decodes the payload. In the browser, something has to fetch the payload, parse it, load the referenced chunks, and hand React a tree to render.
React provides the pieces for 1 and 3 in packages named after the bundler they integrate with: react-server-dom-webpack, react-server-dom-turbopack, react-server-dom-parcel, and others. Piece 2, the bundler integration, is the hard part.
On top of those, a real app needs routing (which Server Component tree does this URL render?), SSR for the first load (turning the payload into HTML), and Server Functions (calling "use server" functions from the client). Frameworks bundle all of this. Doing it yourself means building each one.
Rendering an RSC Payload by Hand
You can see the payload format with nothing but Node and React's server package, as long as the tree contains no Client Components. Install the packages, keeping react-server-dom-webpack on exactly the same version as react:
npm install react react-dom react-server-dom-webpack express
npm install -D tsx @types/express @types/react
Make sure your tsconfig.json uses the automatic JSX runtime with "jsx": "react-jsx". Then write a server that renders a Server Component to the payload format:
// rsc-server.tsx
import express from "express";
import { renderToPipeableStream } from "react-server-dom-webpack/server";
type Post = { id: number; title: string };
async function getPosts(): Promise<Post[]> {
await new Promise((resolve) => setTimeout(resolve, 300));
return [
{ id: 1, title: "Server Components, explained" },
{ id: 2, title: "Why the payload isn't HTML" },
];
}
async function PostList() {
const posts = await getPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
function Page() {
return (
<main>
<h1>Latest posts</h1>
<PostList />
</main>
);
}
const app = express();
app.get("/rsc", (_req, res) => {
res.setHeader("Content-Type", "text/x-component");
// No client components yet, so the client manifest can be empty
const { pipe } = renderToPipeableStream(<Page />, {});
pipe(res);
});
app.listen(3001, () => console.log("RSC server on http://localhost:3001/rsc"));
Run it with the react-server condition so React resolves to its server build:
node --conditions react-server --import tsx rsc-server.tsx
Then fetch the payload:
curl http://localhost:3001/rsc
You'll get a few lines that look roughly like this (the exact encoding is an internal detail and changes between versions):
1:["$","ul",null,{"children":[["$","li","1",{"children":"Server Components, explained"}],["$","li","2",{"children":"Why the payload isn't HTML"}]]}]
0:["$","main",null,{"children":[["$","h1",null,{"children":"Latest posts"}],"$L1"]}]
Three things are worth noticing:
- It's not HTML. It's a line-based format describing React elements, so the client can merge it into an existing tree without losing state.
- It streams.
$L1is a placeholder for a chunk that arrives later. Async components that take longer stream in as separate rows. - The components are gone.
PageandPostListwere executed on the server. Only their output remains. Their code, and thegetPostsfunction, never ship.
If you remove --conditions react-server and run the same file, React throws an error telling you the server package must be used with that condition. That's the first piece of the puzzle in action.
Decoding the Payload in the Browser
On the client, react-server-dom-webpack/client turns the stream back into React elements. createFromFetch returns a thenable, which pairs naturally with the use hook:
// src/client.tsx
import { use, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { createFromFetch } from "react-server-dom-webpack/client";
const payload = createFromFetch<ReactNode>(fetch("/rsc"));
function Root() {
return use(payload);
}
createRoot(document.getElementById("root")!).render(<Root />);
The react-server-dom-* packages don't ship their own TypeScript types. If you aren't using a community types package, a small declaration covers what this example uses:
// src/rsc-client.d.ts
declare module "react-server-dom-webpack/client" {
export function createFromFetch<T>(response: Promise<Response>): Promise<T>;
}
For this tree, decoding is straightforward, because every element is a plain HTML tag. If you want to understand how use suspends on a promise like this, see exploring the React use hook.
This client module must be bundled with webpack, because the webpack flavor of the client runtime loads Client Component chunks through webpack's runtime. React ships a webpack plugin that finds "use client" files and writes the manifest:
// webpack.config.js
const path = require("node:path");
const ReactServerWebpackPlugin = require("react-server-dom-webpack/plugin");
module.exports = {
mode: "development",
entry: "./src/client.tsx",
output: {
path: path.resolve(__dirname, "dist"),
filename: "client.js",
publicPath: "/",
},
resolve: { extensions: [".tsx", ".ts", ".js"] },
module: {
rules: [{ test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ }],
},
plugins: [new ReactServerWebpackPlugin({ isServer: false })],
};
The build emits react-client-manifest.json next to your bundle. That file is the webpack map the server passes as the second argument to renderToPipeableStream, instead of the empty object above.
Where It Gets Hard: Client Components
Now add the LikeButton back. For the server to emit a client reference, the import { LikeButton } in your server code must not load the real component. It must load a stub registered as a client reference. React exposes the building block for this:
import { registerClientReference } from "react-server-dom-webpack/server";
// What a bundler generates in place of a "use client" module on the server
export const LikeButton = registerClientReference(
() => {
throw new Error("LikeButton can't be called on the server");
},
"file:///app/src/LikeButton.tsx",
"LikeButton",
);
The ID has to match a key in the client manifest exactly, and the manifest has to point at chunks that really contain the component. Keeping three things in sync (the server stub, the browser chunk, and the manifest) across every "use client" file, through hot reloads and production builds, is precisely the bundler integration that's missing when you go frameworkless.
React's own repository has a working reference setup in its fixtures/flight directory. It uses a Node module hook (react-server-dom-webpack/node-register) to rewrite "use client" modules on the server, Babel to transform JSX, and two separate server processes: one with the react-server condition that produces the payload, and one without it that turns the payload into HTML for the first load. Reading it is the best way to see the real moving parts, and it makes clear why most people don't want to maintain that themselves.
Server Functions add a second channel
Server Functions ("use server") work in the opposite direction. The bundler replaces them on the client with stubs that serialize arguments with encodeReply and POST them to the server. The server decodes them with decodeReply, looks the function up in a server manifest, calls it, and usually re-renders the tree. It's another protocol to wire up and another place where security matters: these endpoints accept input from anyone, so validate every argument. The server-side RSC packages have also had serious vulnerabilities disclosed, including a critical remote code execution issue in late 2025, so keep react-server-dom-* patched to the latest release in its line.
SSR is a separate step
The payload isn't HTML, so on its own it gives you client-side rendering with a server-provided tree. For a fast first paint you also need SSR: decode the payload on the server (in an environment without the react-server condition, since Client Components need the regular React build), render it to HTML with react-dom/server, and inline the payload in the page so the client can hydrate without fetching it again. The comparison of server-side and client-side rendering covers why that first paint matters.
Practical Options Without a Full Framework
Hand-wiring webpack is educational, but there are now tools that provide the bundler layer and nothing more. They sit between "do everything yourself" and "adopt Next.js."
Vite with @vitejs/plugin-rsc
The Vite team maintains @vitejs/plugin-rsc, which implements the bundler integration using Vite's Environment API. It builds three environments from one project: rsc (with the react-server condition), ssr, and client. It handles "use client" and "use server" directives, the manifests, CSS, and hot reload.
npm install react react-dom
npm install -D vite @vitejs/plugin-react @vitejs/plugin-rsc
You still write the entry points yourself: a server entry that renders your root Server Component to a payload stream, an SSR entry that converts that stream to HTML, and a browser entry that hydrates and handles navigation and Server Function calls. The plugin repository includes starter examples that show these three files. Several newer frameworks use this plugin under the hood, which is a good sign it's the foundation the ecosystem is converging on.
Parcel
Parcel has built-in support for Server Components. It understands the directives natively and provides helpers for rendering requests, so a small server plus a few annotated entry files is enough. It's a good fit if you want RSC in a mostly static or multi-page site without adopting a routing framework.
React Router
React Router v7 has been adding Server Component support to its framework and data modes, built on the same bundler integrations. If you already use it, that's likely the least disruptive path. The post on React Router v7 covers its modes.
So, Should You Do It?
Here's an honest comparison:
| Approach | What you write | Good for |
|---|---|---|
Hand-wired react-server-dom-* | Bundler config, manifests, servers, routing, SSR, actions | Learning how RSC works |
| Vite plugin-rsc or Parcel | Entry points, routing, data loading | Custom setups, libraries, small apps |
| A framework (Next.js, React Router, others) | Your components | Most production apps |
Going frameworkless makes sense if you're building a framework or library, if you need RSC inside an unusual architecture like an existing non-Node backend or an embedded tool, or if you want to understand the model deeply. It's a poor trade if your goal is simply to ship an app faster. You'd spend your time on plumbing that frameworks have already debugged.
It's also worth asking whether you need Server Components at all. If your app is a client-heavy dashboard with an existing API, a client-side setup with good data fetching may be simpler and just as fast for your users. Server Components pay off most when you have lots of content-heavy UI that doesn't need interactivity, and large dependencies (markdown parsers, syntax highlighters, date libraries) you'd rather not send to the browser.
Common Misconceptions
- "Server Components are the same as SSR." SSR produces HTML from all components and still ships all their code. Server Components ship no code for themselves and produce a payload, not HTML.
- "I can add
asyncto a component in my Vite app and it becomes a Server Component." Without the server environment, bundler integration, and client runtime, anasynccomponent in a client-only app is just an error. - "
use clientmeans the component only renders in the browser." Client Components still render on the server during SSR. The directive marks the boundary where code starts shipping to the browser. - "The payload format is a stable API." It's an internal protocol between matching versions of React packages. Always keep
reactandreact-server-dom-*on the same version. - "Server Functions are private." They're public HTTP endpoints. Authenticate and validate inside every one.
Frequently Asked Questions (FAQ) About Server Components Without a Framework
Yes, with the @vitejs/plugin-rsc plugin, which adds the server, SSR, and client environments that Server Components need. You still write the entry points and routing yourself. Without that plugin or something similar, a plain Vite app can't run Server Components.
When server code imports a use client file, something must swap that import for a client reference and put the real component into a browser chunk, then record the mapping in a manifest. React can't do that at runtime, because it's a build-time transformation across two separate module graphs.
It's a package export condition that makes react resolve to a special server build where client-only APIs like useState and useEffect aren't available. Server Component code must run with it, for example via node --conditions react-server. Bundlers set it automatically in their RSC environment.
No. It's a streaming, line-based serialization of the React tree, including placeholders for content that's still loading and references to Client Component chunks. It can be turned into HTML on the server for the first load, but its main job is letting the client merge server output into a live tree.
They need a JavaScript server runtime, but not necessarily Node. React provides edge builds of its server packages that work with web streams, so runtimes like Deno, Bun, and Cloudflare Workers can host them, given a bundler that targets them.
React's APIs are stable, but the bundler integrations outside Next.js are younger and change faster. Low-level tools like the Vite plugin and Parcel are usable today, but expect more upgrade work and fewer guides. For most production apps a framework is still the safer choice.
Conclusion
Server Components are a React feature, but they depend on three cooperating parts: a server running React's react-server build, a bundler that turns "use client" imports into client references with a manifest, and a client runtime that decodes the streamed payload. You can render a payload with plain Node in a few lines. The hard part is everything around Client Components, Server Functions, and SSR, which is exactly what frameworks provide.
If you want to understand the model, run the payload example above, then read through React's flight fixture. If you want to use Server Components in a custom setup, start with @vitejs/plugin-rsc or Parcel rather than hand-wiring webpack. And if you just want to ship, a framework that has already solved the plumbing is still the most productive route.


