Learn how to set up and use Server-Side Rendering (SSR) in Sonamu.
SSR Overview
TanStack Router File-based routing Type-safe navigation
Vite SSR Fast builds HMR support
Direct API Call No HTTP overhead Fast response
TanStack Query Automated Hydration Caching integration
Sonamu SSR Architecture
Sonamu implements SSR using TanStack Router + Vite .
Core Components
entry-server.generated.tsx
Entry point for rendering React on the server (auto-generated):
// web/src/entry-server.generated.tsx
import { QueryClient , dehydrate } from "@tanstack/react-query" ;
import { createMemoryHistory , createRouter , RouterProvider } from "@tanstack/react-router" ;
import { Suspense } from "react" ;
import { renderToString } from "react-dom/server" ;
import { routeTree } from "./routeTree.gen" ;
export type PreloadedData = {
queryKey : any [];
data : any ;
};
export async function render ( url : string , preloadedData : PreloadedData [] = []) {
// Create QueryClient
const queryClient = new QueryClient ({
defaultOptions: {
queries: {
staleTime: 5000 ,
retry: false ,
},
},
});
// Inject preloaded data directly into queryClient
for ( const { queryKey , data } of preloadedData ) {
queryClient . setQueryData ( queryKey , data );
}
// Dehydrate
const dehydratedState = dehydrate ( queryClient );
// Create memory history for SSR
const memoryHistory = createMemoryHistory ({
initialEntries: [ url ],
});
// Create Router (SSR mode)
const router = createRouter ({
routeTree ,
context: { queryClient },
history: memoryHistory ,
defaultPreload: "intent" ,
});
// Initialize router: Must call await router.load() in SSR
await router . load ();
// Render only RouterProvider (wrapped in Suspense - prevents hydration mismatch)
const appHtml = renderToString (
< Suspense fallback = { null } >
< RouterProvider router = { router } />
</ Suspense > ,
);
return {
html: appHtml ,
dehydratedState ,
};
}
entry-client.tsx
Entry point for hydrating React on the client:
// web/src/entry-client.tsx
import { hydrate , QueryClient } from "@tanstack/react-query" ;
import { createRouter , RouterProvider } from "@tanstack/react-router" ;
import ReactDOM from "react-dom/client" ;
import { routeTree } from "./routeTree.gen" ;
import { dateReviver } from "./services/sonamu.shared" ;
// SSR data type
declare global {
interface Window {
__SONAMU_SSR__ ?: any ;
__SONAMU_SSR_CONFIG__ ?: {
disableHydrate ?: boolean ;
};
}
}
// Create QueryClient
const queryClient = new QueryClient ({
defaultOptions: {
queries: {
staleTime: 5000 ,
retry: false ,
refetchOnMount: true ,
},
},
});
// Restore SSR data
const dehydratedState = window . __SONAMU_SSR__
? JSON . parse ( JSON . stringify ( window . __SONAMU_SSR__ ), dateReviver )
: undefined ;
if ( dehydratedState ) {
hydrate ( queryClient , dehydratedState );
}
// Create Router
const router = createRouter ({
routeTree ,
context: { queryClient },
defaultPreload: "intent" ,
});
await router . load ();
// Hydration
if ( document . documentElement . innerHTML && dehydratedState ) {
// SSR page - Hydration
ReactDOM . hydrateRoot ( document , < RouterProvider router ={ router } />);
} else {
// Pure CSR page - Render new
ReactDOM . createRoot ( document ). render (< RouterProvider router ={ router } />);
}
TanStack Router Route Structure
Root Route
// web/src/routes/__root.tsx
import { QueryClient , QueryClientProvider } from "@tanstack/react-query" ;
import { createRootRouteWithContext , HeadContent , Outlet , Scripts } from "@tanstack/react-router" ;
export interface RouterContext {
queryClient : QueryClient ;
}
export const Route = createRootRouteWithContext < RouterContext >()({
head : () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport" , content: "width=device-width, initial-scale=1.0" },
{ title: "My App" },
],
}),
component: RootComponent ,
});
function RootComponent () {
const { queryClient } = Route . useRouteContext ();
return (
< html lang = "en" >
< head >
< HeadContent />
</ head >
< body >
< div id = "root" >
< QueryClientProvider client = { queryClient } >
< Outlet />
</ QueryClientProvider >
</ div >
< Scripts />
</ body >
</ html >
);
}
File Route
// web/src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router" ;
export const Route = createFileRoute ( "/" )({
component: HomePage ,
});
function HomePage () {
return (
< div >
< h1 > Home Page </ h1 >
</ div >
);
}
Dynamic Route
// web/src/routes/users/$id.tsx
import { createFileRoute } from "@tanstack/react-router" ;
import { UserService } from "@/services/services.generated" ;
export const Route = createFileRoute ( "/users/$id" )({
component: UserPage ,
});
function UserPage () {
const { id } = Route . useParams ();
const { data } = UserService . useUser ( "C" , parseInt ( id ));
return (
< div >
< h1 >{data?.user. username } </ h1 >
< p >{data?.user. email } </ p >
</ div >
);
}
Vite Configuration
// web/vite.config.ts
import { tanstackRouter } from "@tanstack/router-plugin/vite" ;
import react from "@vitejs/plugin-react" ;
import { defineConfig } from "vite" ;
export default defineConfig (({ isSsrBuild }) => ({
plugins: [
react (),
tanstackRouter ({
autoCodeSplitting: true ,
generatedRouteTree: "./src/routeTree.gen.ts" ,
}),
] ,
build: {
outDir: "dist/client" ,
rollupOptions: {
output: isSsrBuild
? {}
: {
manualChunks: {
"vendor-react" : [ "react" , "react-dom" ],
"vendor-tanstack" : [ "@tanstack/react-query" , "@tanstack/react-router" ],
},
},
},
} ,
ssr: {
noExternal: true , // Include all dependencies in bundle for production build
} ,
})) ;
SSR vs CSR
Sonamu uses a hybrid approach :
SSR Rendering (when data preloading)
// Routes registered with registerSSR
// β Generate HTML on server + include data
// β Hydration on client
Benefits :
Fast First Contentful Paint
SEO optimization
Immediate display of initial data
CSR Rendering (default)
// Routes without registerSSR
// β Render directly on client
// β Load data via API call
Benefits :
Reduced server load
Simple implementation
Fast development
Development vs Production
Development Mode
sonamu dev # Integrated mode (= sonamu dev all)
sonamu dev all # Integrated mode (one-port: API + Web)
sonamu dev api # API-only mode (Vite integration disabled)
sonamu dev web # Vite standalone
sonamu dev web -- --port 5173 --host 0.0.0.0 # Pass Vite options
dev all: API server integrates Vite to serve API + Web on a single port (HMR supported)
dev api: Runs API only. Use when Web is not needed or when running Vite separately
dev web: Runs Vite dev server standalone. Pass Vite options after --
Production Build
sonamu build # Full build (= sonamu build all)
sonamu build api # API only
sonamu build web # Web only
Build output :
web/dist/ # Web build source
βββ client/ # Client bundle
βββ server/ # SSR bundle
api/web-dist/ # Copied from web/dist (for deployment)
βββ client/ # = web/dist/client copy
β βββ index.html
β βββ assets/
βββ server/ # = web/dist/server copy
βββ entry-server.generated.js
api/dist/ # API build output
βββ ssr/
βββ routes.js # SSR routes (API owned)
Cautions
Cautions when using Sonamu SSR : 1. TanStack Router based : Not Next.js App Router 2.
entry-server.generated.tsx is auto-generated : Donβt modify directly 3. Be careful with
window object : window doesnβt exist on server 4. dateReviver required : Handles Date object
serialization/deserialization 5. Use registerSSR : See next document for data preloading
Next Steps
Data Preloading How to use registerSSR
Hydration Strategies Hydration optimization
Cache Control TanStack Query caching
TanStack Router Docs Router detailed guide