Skip to main content
Server-side rendering (SSR) enables rendering your application on the server, sending fully-rendered HTML to the client for faster initial page loads and better SEO.

SSR setup

1

Install SSR packages

For React Router with SSR:
npm install @tanstack/react-router
npm install -D @tanstack/router-plugin
2

Configure for SSR

Create separate client and server entry points:
src/entry-client.tsx
import ReactDOM from 'react-dom/client'
import { StartClient } from '@tanstack/react-start/client'
import { createRouter } from './router'

const router = createRouter()

ReactDOM.hydrateRoot(
  document.getElementById('root')!,
  <StartClient router={router} />
)
src/entry-server.tsx
import { renderToString } from 'react-dom/server'
import { StartServer } from '@tanstack/react-start/server'
import { createMemoryHistory } from '@tanstack/react-router'
import { createRouter } from './router'

export async function render(url: string) {
  const router = createRouter()
  
  const memoryHistory = createMemoryHistory({
    initialEntries: [url],
  })
  
  router.update({ history: memoryHistory })
  
  await router.load()
  
  const html = renderToString(<StartServer router={router} />)
  
  return html
}
3

Create HTML template

export function createHtml(content: string) {
  return `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>My App</title>
      </head>
      <body>
        <div id="root">${content}</div>
        <script type="module" src="/src/entry-client.tsx"></script>
      </body>
    </html>
  `
}

Using TanStack Start

For the easiest SSR setup, use TanStack Start:
app.config.ts
import { defineConfig } from '@tanstack/react-start/config'

export default defineConfig({
  // SSR is enabled by default
})
src/routes/__root.tsx
import { createRootRoute, Outlet, ScrollRestoration } from '@tanstack/react-router'
import { Meta, Scripts } from '@tanstack/react-start'

export const Route = createRootRoute({
  component: () => (
    <html>
      <head>
        <Meta />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  ),
})

Server-side data loading

Loaders run on the server during SSR:
src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { fetchPost } from '@/api/posts'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    // Runs on server during SSR
    const post = await fetchPost(params.postId)
    return { post }
  },
  component: PostPage,
})

function PostPage() {
  const { post } = Route.useLoaderData()
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

Hydration

Dehydrate server state and rehydrate on client:
src/entry-server.tsx
import { DehydrateRouter } from '@tanstack/react-router'

export async function render(url: string) {
  const router = createRouter()
  // ... setup router
  
  await router.load()
  
  const html = renderToString(
    <>
      <StartServer router={router} />
      <DehydrateRouter router={router} />
    </>
  )
  
  return html
}
src/entry-client.tsx
import { HydrateRouter } from '@tanstack/react-router'

const router = createRouter()

ReactDOM.hydrateRoot(
  document.getElementById('root')!,
  <HydrateRouter router={router}>
    <StartClient router={router} />
  </HydrateRouter>
)

Client-only components

Render components only on the client:
import { ClientOnly } from '@tanstack/react-router'

function MyPage() {
  return (
    <div>
      <h1>This renders on server</h1>
      <ClientOnly fallback={<Skeleton />}>
        {() => <BrowserOnlyComponent />}
      </ClientOnly>
    </div>
  )
}

Detecting SSR

Check if code is running on server:
import { isServer } from '@tanstack/react-router'

function MyComponent() {
  React.useEffect(() => {
    if (!isServer) {
      // Client-only code
      console.log('Running on client')
    }
  }, [])
  
  return <div>Content</div>
}

Streaming SSR

Stream HTML as it’s generated:
src/entry-server.tsx
import { renderToPipeableStream } from 'react-dom/server'

export function renderStream(url: string, res: Response) {
  const router = createRouter()
  // ... setup router
  
  const { pipe } = renderToPipeableStream(
    <StartServer router={router} />,
    {
      onShellReady() {
        res.setHeader('Content-Type', 'text/html')
        pipe(res)
      },
      onError(error) {
        console.error(error)
      },
    }
  )
}

SEO and meta tags

Set meta tags from routes:
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    return { post }
  },
  meta: ({ loaderData }) => [
    { title: loaderData.post.title },
    { name: 'description', content: loaderData.post.excerpt },
    { property: 'og:title', content: loaderData.post.title },
    { property: 'og:image', content: loaderData.post.image },
  ],
})

Environment variables

Access env vars safely:
// Server-only env vars
if (isServer) {
  const apiKey = process.env.SECRET_API_KEY
}

// Public env vars (prefixed with PUBLIC_)
const publicUrl = import.meta.env.PUBLIC_API_URL

Error handling in SSR

Handle errors during server rendering:
export async function render(url: string) {
  try {
    const router = createRouter()
    await router.load()
    return renderToString(<StartServer router={router} />)
  } catch (error) {
    console.error('SSR Error:', error)
    // Return error page HTML
    return renderToString(<ErrorPage error={error} />)
  }
}

Deferred data in SSR

Stream deferred data after initial render:
import { defer, Await } from '@tanstack/react-router'

export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    // Critical data loads first
    const user = await fetchUser()
    
    // Non-critical data is deferred
    const stats = defer(fetchStats())
    
    return { user, stats }
  },
})

function Dashboard() {
  const { user, stats } = Route.useLoaderData()
  
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <Suspense fallback={<Skeleton />}>
        <Await promise={stats}>
          {(data) => <Stats data={data} />}
        </Await>
      </Suspense>
    </div>
  )
}

Static generation

Pre-render routes at build time:
build.ts
import { renderToString } from 'react-dom/server'
import { createRouter } from './router'
import { writeFileSync } from 'fs'

const routes = ['/', '/about', '/contact']

for (const route of routes) {
  const router = createRouter()
  // ... render route
  const html = renderToString(<StartServer router={router} />)
  
  writeFileSync(`dist${route}/index.html`, html)
}

Best practices

Use loaders for data needed for initial render to ensure it’s available during SSR.
Wrap code that uses window, document, or other browser APIs in ClientOnly.
Keep server-only code separate and use tree-shaking to reduce bundle size.
Implement caching strategies for frequently-accessed pages.
Stream HTML as it’s generated for faster time-to-first-byte.

Performance tips

Preload critical routes: Preload routes users are likely to visit next.
Use deferred data: Defer non-critical data to improve initial page load.
Avoid heavy computations in loaders - cache results or move to background jobs.

Next steps

TanStack Start

Full-stack framework with SSR

Data loading

Optimize server data fetching

Build docs developers (and LLMs) love