Source folders default to client-side rendering with Vite's dev server and HMR. The SSR generator adds production-ready server rendering while keeping your development workflow unchanged.
π οΈ Adding SSR Support β
SSR is automatically enabled if selected during source folder creation. To add it to an existing folder, register ssrGenerator in your source folder's kosmo.config.ts:
import {
defineConfig,
// ...other generators
ssrGenerator,
} from "@kosmojs/dev";
export default defineConfig({
generators: [
// ...other generators
ssrGenerator(),
],
});π Server Entry Point β
The SSR generator creates entry/server.tsx (or .vue) with a default implementation. renderFactory accepts a callback returning an object with rendering methods:
renderToString(url, SSROptions)- renders the complete page before transmission. Provided by default.renderToStream(url, SSROptions)- optional progressive streaming implementation. When provided, takes precedence overrenderToString.
import { renderToString } from "react-dom/server";
import renderFactory, { createRoutes } from "_/entry/server";
import routerFactory from "../router";
const routes = createRoutes({ withPreload: false });
const { serverRouter } = routerFactory(routes);
export default renderFactory(() => {
return {
async renderToString(url, { assets }) {
const page = await serverRouter(url);
const head = assets.map(({ tag }) => tag).join("\n");
const html = renderToString(page);
return { head, html };
},
};
});import { renderToString, generateHydrationScript } from "solid-js/web";
import renderFactory, { createRoutes } from "_/entry/server";
import routerFactory from "../router";
const routes = createRoutes({ withPreload: false });
const { serverRouter } = routerFactory(routes);
export default renderFactory(() => {
const hydrationScript = generateHydrationScript();
return {
async renderToString(url, { assets }) {
const page = await serverRouter(url);
const head = assets.reduce(
(head, { tag }) => `${head}\n${tag}`,
hydrationScript,
);
const html = renderToString(() => page);
return { head, html };
},
};
});import { renderToString } from "vue/server-renderer";
import renderFactory, { createRoutes } from "_/entry/server";
import routerFactory from "../router";
const routes = createRoutes();
const { serverRouter } = routerFactory(routes);
export default renderFactory(() => {
return {
async renderToString(url, { assets }) {
const page = await serverRouter(url);
const head = assets.map(({ tag }) => tag).join("\n");
const html = await renderToString(page);
return { head, html };
},
};
});import { renderToString } from "preact-render-to-string";
import renderFactory, { createRoutes } from "_/entry/server";
import { renderHead } from "_/mdx";
import routerFactory from "../router";
const routes = createRoutes();
const { serverRouter } = routerFactory(routes);
export default renderFactory(() => {
return {
async renderToString(url, { assets }) {
const page = await serverRouter(url);
const head = assets.reduce(
(head, { tag }) => `${head}\n${tag}`,
renderHead(page?.frontmatter),
);
const html = page ? renderToString(page.component) : "";
return { html, head };
},
};
});renderToString receives the URL being requested and must return:
html- the rendered application markuphead- HTML to inject into<head>(optional)
SolidJS injects a hydration script in <head> via generateHydrationScript(), which bootstraps client-side reactivity during hydration.
ποΈ Render Factory Arguments β
renderToString receives two arguments - the URL and SSROptions:
export type SSROptions = {
// The original client index.html output from Vite build.
// Contains <!--app-head--> and <!--app-html--> placeholders
// where SSR content should be injected.
template: string;
// Vite's final manifest.json - the full dependency graph for
// client modules, dynamic imports, and related CSS.
manifest: Manifest;
// SSR-related assets, must be injected manually (unlike CSR assets that are injected by Vite).
// Each entry provides three ways to consume the asset:
// - `tag`: ready-to-use HTML tag (<script> or <link>) for direct injection
// - `path`: asset URL for building custom tags with additional attributes
// - `content`: raw file contents for inlining as <style> or inline <script>
// `size` is included for Content-Length or preload hints.
assets: Array<{
tag: string;
kind: "js" | "css";
path: string;
content: string | undefined;
size: number | undefined;
}>;
};| Property | Description |
|---|---|
template | Client index.html from the Vite build, with <!--app-head--> and <!--app-html--> placeholders for SSR injection |
manifest | Vite's manifest.json - the full dependency graph for client modules |
assets | SSR-related assets, must be injected manually |
π Stream Rendering β
When both provided, renderToStream takes precedence over renderToString, enabling earlier flushing and improved Time-to-First-Byte (TTFB).
renderToStream receives the request URL, SSR options, and a Hono StreamingApi instance. The SSR server creates the stream and passes it to your renderer:
import { stream } from "hono/streaming";
// renderToStream receives full control over the response stream.
// The renderer decides when to flush the shell, inject assets,
// and finalize the response.
return stream(ctx, async (stream) => {
await renderToStream(url, ssrOptions, stream);
});The pattern is the same across frameworks: split the HTML template at <!--app-html-->, write the opening HTML with head content, pipe the framework's rendered stream, then write the closing HTML.
Frameworks provide a web-standard ReadableStream renderer, which pipes directly into Hono's stream.pipe() - no Node.js stream adapters needed, works identically on Node, Bun, and Deno.
import { renderToReadableStream } from "react-dom/server";
export default renderFactory(() => {
return {
// ...
async renderToStream(url, { template, assets }, stream) {
const { router } = await serverRouter(url);
const head = assets
.map(({ tag }) => tag)
.join("\n");
const [htmlStart, htmlEnd] = template.split("<!--app-html-->");
await stream.write(htmlStart.replace("<!--app-head-->", head));
const reactStream = await renderToReadableStream(router);
await stream.pipe(reactStream);
await stream.write(htmlEnd);
},
};
});import { renderToStream } from "solid-js/web";
export default renderFactory(() => {
const hydrationScript = generateHydrationScript();
return {
// ...
async renderToStream(url, { template, assets }, stream) {
const { router } = await serverRouter(url);
const head = assets.reduce(
(head, { tag }) => `${head}\n${tag}`,
hydrationScript,
);
const [htmlStart, htmlEnd] = template.split("<!--app-html-->");
await stream.write(htmlStart.replace("<!--app-head-->", head));
const { readable } = renderToStream(() => router);
await stream.pipe(readable);
await stream.write(htmlEnd);
},
};
});import { createSSRApp } from "vue";
import { renderToWebStream } from "vue/server-renderer";
export default renderFactory(() => {
return {
// ...
async renderToStream(url, { template, assets }, stream) {
const { app } = await serverRouter(url);
const head = assets
.map(({ tag }) => tag)
.join("\n");
const [htmlStart, htmlEnd] = template.split("<!--app-html-->");
await stream.write(htmlStart.replace("<!--app-head-->", head));
const vueStream = renderToWebStream(app);
await stream.pipe(vueStream);
await stream.write(htmlEnd);
},
};
});Same web-standard ReadableStream used across all frameworks:
- React -
renderToReadableStreamreturns aReadableStreamdirectly. Cross-runtime, replaces the Node-onlyrenderToPipeableStream. - SolidJS -
renderToStreamreturns{ readable }, a webReadableStream. - Vue -
renderToWebStreamreturns aReadableStream, replacing the Node-onlyrenderToNodeStream.
Hono's stream.pipe(readableStream) consumes each framework's output identically - no runtime-specific adapters or Node.js stream conversions.
π¦ Static Asset Handling β
By default the SSR server loads client assets into memory at startup and serves them on request. Disable this when running behind a reverse proxy or CDN:
export default defineConfig({
// ...
generators: [
// ...
ssrGenerator({
serveStaticAssets: false,
}),
],
});ποΈ Production Build β
npm run buildpnpm buildyarn buildProduces an SSR bundle at dist/SOURCE_FOLDER/ssr/server.js, ready for production execution.
π§ͺ Local Testing β
Test your SSR bundle before deploying:
node dist/front/ssr/server.js -p 4556Navigate to http://localhost:4556 to verify server-side rendering.
π₯οΈ Runtime β
The SSR server uses node:http which is natively supported by Node, Bun, and Deno. Same bundle, same behavior, just pick your runtime:
node dist/front/ssr/server.js -p 4556bun dist/front/ssr/server.js -p 4556deno run -A dist/front/ssr/server.js -p 4556Unix sockets are also supported across all three runtimes:
node dist/front/ssr/server.js -s /tmp/app.sockπ Production Deployment β
Deploy behind a reverse proxy such as Nginx or Caddy:
upstream ssr_backend {
server 127.0.0.1:4556;
# server unix:/tmp/app.sock;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://ssr_backend;
}
}π Development Experience β
SSR activates exclusively in production builds. During development:
- Run
pnpm devas usual - Vite handles all requests with HMR
- Client-side rendering provides immediate feedback
π‘ Production Guidelines β
- Test locally before deploying. Always verify your production bundle renders correctly before pushing to live servers.
- Use streaming for large pages. Applications with substantial HTML or complex data-fetching chains benefit from
renderToStream- users see content faster as it arrives progressively. - Monitor process resources. SSR keeps Node.js processes running continuously. Track memory consumption and implement error handling to prevent leaks.
- Cache aggressively. Place a CDN or cache layer in front of your SSR server for infrequently changing routes to reduce server load.
- Implement error boundaries. Add error boundaries throughout your application and handle errors in server entry points. Server errors shouldn't terminate the entire process.
- Separate SSR and CSR concerns via source folders. Rather than complex route-level SSR/CSR switching within a single folder, use
KosmoJS's architectural strength: deploy an SSR source folder for marketing content and a CSR source folder for your application. Cleaner codebases, straightforward maintenance.
β οΈ Technical Considerations β
- Browser APIs unavailable during SSR. Code executing server-side cannot access
window,document, or browser-exclusive APIs. - Coordinate async data loading. Suspense and resources work in SSR contexts, but complex async patterns require careful attention to ensure data is ready before rendering.
- Bundle size still matters. In SSR, initial bundle size affects server memory and startup time. The hydration bundle still downloads to clients, so optimization remains important.
- Plan state serialization. Applications with complex state require proper serialization for hydration. Each framework handles standard cases automatically, but custom stores or non-serializable data need special attention.