Skip to main content
Learn how to set up and use Server-Side Rendering (SSR) in Sonamu.

SSR Overview

TanStack Router

File-based routingType-safe navigation

Vite SSR

Fast buildsHMR support

Direct API Call

No HTTP overheadFast response

TanStack Query

Automated HydrationCaching integration

Sonamu SSR Architecture

Sonamu implements SSR using TanStack Router + Vite.

Core Components

πŸ“web/src/
πŸ“„TSXentry-server.generated.tsx - Server rendering entry (auto-generated)
πŸ“„TSXentry-client.tsx - Client hydration entry
πŸ“„TSrouteTree.gen.ts - TanStack Router route tree (auto-generated)
πŸ“routes/ - File-based routes
πŸ“„TSX__root.tsx - Root layout
πŸ“„TSXindex.tsx - / path
πŸ“users/
πŸ“„TSX$id.tsx - /users/:id path

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-swc";
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 3028 --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