How to Keep Bun Fullstack HMR While Adding Custom SSR (No Proxy, No Vite)
HMR + Custom SSR in Bun (Without Breaking Fullstack Dev)
How to Keep Bun Fullstack HMR While Adding Custom SSR (No Proxy, No Vite)

HMR + Custom SSR in Bun (Without Breaking Fullstack Dev)
Bun’s fullstack dev server gives you instant HMR out of the box. But the moment you try to add custom SSR, everything breaks.
Why ?
Because Bun injects its HMR runtime directly into your index.html. If you replace that HTML yourself, you accidentally remove the bootstrap script that powers HMR.
In this article, we’ll explore:
- Why naive SSR kills Bun HMR
- How Bun transforms HTML internally
- And the trick to keep both SSR and HMR working — in a single process, without Vite or a proxy.
This is a trick and Bun may add the proper primitive in the futur. As of Bun v.1.3.10, there is not equivalent to Vites’s
*transformIndexHtml()or `ssrLoadModule()`*.
🧠 Understanding the Problem
When you run Bun in fullstack dev mode, it doesn’t just serve your index.html.
It transforms it.
If you inspect the HTML in the browser, you’ll see something like:
<link rel="stylesheet" href="/_bun/asset/abc123.css">
<script type="module" src="/_bun/client/index-abc123.js" data-bun-dev-server-script></script>
That / _bun/client/... script is the real entrypoint.
It:
- Connects to the WebSocket
- Registers HMR boundaries
- Boots the module graph
In other words:
HMR does not live in your HTML. It lives in the injected Bun runtime.
💥 Why Naive SSR Breaks HMR
The most natural way to add SSR is something like:
return new Response(await Bun.file("index.html").text())
Or using Elysia static plugin:
new Elysia().use(staticPlugin({ prefix: "/" }))
The problem?
You just bypassed Bun’s HTML transform pipeline.
Your HTML no longer contains:
<script type="module" src="/_bun/client/index-abc123.js" data-bun-dev-server-script></script>
Althought this works productively, you will basically have no injected runtime, no websocket and thus no HMR.
🔍 What We Actually Need
What we really want is:
- Let Bun process
index.html - Keep its injected HMR runtime
- Inject our SSR markup into the transformed HTML
- Return the result
In Vite, you’d use:
transformIndexHtml()ssrLoadModule()
But Bun doesn’t expose such primitives (yet).
So we need another way.
🧪 The Trick
The key insight:
Bun transforms HTML only when it owns the route.
So instead of reading index.html directly from disk, we let Bun serve it first. I will show you how to do it using Elysia but it works the same with Bun serve.
Step 1 — Expose a Bun-owned route with the index.html and hydration script
import indexHtml from "./pages/index.html";
import { staticPlugin } from "@elysiajs/static";
const app = new Elysia().use(
await staticPlugin({
assets: `${import.meta.dir}/pages`,
prefix: "/_bun_hmr_entry",
}),
);
Then in the pages/ directory, add your index.html. It basically looks like this, it’s the template that will help us inject the Bun HMR script.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="../public/logo.svg">
<title>Bun + React</title>
</head>
<body>
<div id="root"><!--ssr-outlet--></div>
<script type="module" src="./_hydrate.tsx" async></script>
</body>
</html>
You can see that the html template inject the _hydrate.tsx. Indeed when doing SSR, you need to hydrate with React your entrypoint. You can create this file next to your index.html
import { StrictMode } from "react";
import { createRoot, hydrateRoot } from "react-dom/client";
import { App } from "@/app";
declare global {
interface Window {
__SSR__?: { url: string };
}
}
const elem = document.getElementById("root") as HTMLElement;
const url = window.__SSR__?.url ?? window.location.pathname;
const app = (
<StrictMode>
<App url={url} />
</StrictMode>
);
if (import.meta.hot) {
const root = (import.meta.hot.data.root ??= elem.innerHTML.trim()
? hydrateRoot(elem, app)
: createRoot(elem));
root.render(app);
} else if (elem.innerHTML.trim()) {
hydrateRoot(elem, app);
} else {
createRoot(elem).render(app);
}
This route passes through Bun’s internal dev pipeline.
So when we fetch it, we get:
- Injected
_bun/client/... - Injected assets
- HMR runtime
Step 2 — Fetch the transformed HTML
async function getTemplate() {
const res = await fetch("http://localhost:3000/_bun_entry");
return res.text();
}
Now we’re not reading the raw file, we’re reading the Bun-processed version.
Step 3 — Create the SSR render method
You can then create the render method that will stream you react component with SSR.
import { renderToReadableStream } from "react-dom/server";
import { App } from "./app";
export async function render(url: string): Promise<ReadableStream> {
return renderToReadableStream(<App url={url} />);
}
Here, url is injected from server, but it can be any serialisable data you need.
Step 4— Inject SSR in a catch all routes
const app = new Elysia()
.use(
await staticPlugin({
assets: `${import.meta.dir}/pages`,
prefix: "/_bun_hmr_entry",
}),
)
.get("*", async ({ request, set, server }) => {
const url = new URL(request.url);
// Get the Bun-processed HTML: correct bundle paths + HMR client injected.
const template = await getTemplate(server?.url as URL);
const [before, after] = template.split("<!--ssr-outlet-->");
const payload = JSON.stringify({ url: url.pathname }).replace(
/</g,
"\\u003c",
);
const reactStream = await render(url.pathname);
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode(before));
await reactStream.pipeTo(
new WritableStream({
write(chunk) {
controller.enqueue(chunk);
},
}),
);
controller.enqueue(
encoder.encode(`<script>window.__SSR__=${payload}</script>${after}`),
);
controller.close();
},
});
set.headers["content-type"] = "text/html; charset=utf-8";
return new Response(stream);
})
.listen(PORT);
console.log(`🚀 Server running at ${app.server?.url}`);
And that’s it, HMR keeps working because the injected script is still there. We only modified the SSR outlet.
🧩 Final Architecture
Browser
↓
Elysia SSR layer
↓
fetch("/_bun_entry")
↓
Bun transforms HTML (injects HMR)
↓
Inject SSR
↓
Return final HTML
Single process. No proxy. No Vite. No double dev server.
A minimal working demo is available at https://github.com/Teyik0/basic-bun-hmr-with-ssr
메타데이터
- post_id
- 6ea12c76fe29
- slug
- how-to-keep-bun-fullstack-hmr-while-adding-custom-ssr-no-proxy-no-vite-6ea12c76fe29
- url
- https://medium.com/@teyik0/how-to-keep-bun-fullstack-hmr-while-adding-custom-ssr-no-proxy-no-vite-6ea12c76fe29
- canonical_url
- https://medium.com/@teyik0/how-to-keep-bun-fullstack-hmr-while-adding-custom-ssr-no-proxy-no-vite-6ea12c76fe29
- author_url
- https://medium.com/@teyik0
- status
- ok
- fetched_at
- 2026-06-11 11:25:07