Hello! Welcome to the first lesson of Module 2.
In Module 1, we established the fundamentals of TanStack Start, focusing on its routing, data loading, and unique execution model. Now, we'll begin applying that knowledge to a practical and common scenario: migrating an existing Single-Page Application (SPA) to a Server-Side Rendered (SSR) architecture.
Today's lesson addresses a critical first step in that migration. Your goal is to identify client-only dependencies and refactor them for conditional loading in an SSR environment.
In an SPA, all your code runs in the browser, so you can freely use browser APIs like window or document. However, in an SSR application, your code also runs in a Node.js environment on the server. This environment lacks browser APIs, and attempting to access them will crash your application during the server-rendering process. We'll explore how to identify these dependencies and use TanStack Start's powerful tools to ensure they only run where they're supposed to.
1. The Universal Challenge of SSR: Identifying Client-Only Code
When migrating from an SPA, the first hurdle is that not all code is "universal" or "isomorphic." Some code is fundamentally tied to the browser environment.
A client-only dependency is any piece of code that relies on browser-specific APIs. These APIs are not available on the server.
Common examples include:
- Direct API Access: Code that explicitly uses
window,document,localStorage,navigator,location, etc. - DOM-Dependent Libraries: Charting libraries (like Chart.js or D3) that need to render to a
<canvas>or<svg>, animation libraries, or analytics scripts that manipulate the DOM. - Libraries with Browser-Specific Imports: Some third-party libraries assume a browser environment and access
windowat the top level of their module. Simply importing such a library (import 'some-library') will cause an error on the server before your application code even runs.
To get a clear framework for categorizing your code, please review the "Architecture Decision Framework" in the TanStack Start documentation.
Execution Model | TanStack Start React Docs
This section from the official TanStack Start documentation provides a concise checklist to help you decide whether a piece of logic should be server-only, client-only, or isomorphic.
Read the 'Architecture Decision Framework' section. Pay close attention to the bullet points under 'Choose Client-Only when:'. This will be your guide for identifying problematic code during a migration.
2. Architectural Patterns for Isolation
Before diving into TanStack's specific tools, it's crucial to understand the architectural principle of isolation. The goal is not just to make client-only code work, but to do so without compromising the benefits of SSR (like fast initial loads and smaller client bundles).
A common mistake is to make large parts of your application client-only just to accommodate a small piece of interactive functionality. The best practice is to push client-specific logic to the "leaves" of your component tree.
While the following video discusses Next.js and its "use client" directive, the architectural principle it teaches is universal and directly applicable here.
When & Where to Add “use client” in React / Next.js (Client Components vs Server Components)
This video from ByteGrad explains why it's critical to be granular when defining client-side boundaries. It demonstrates how to isolate client components to prevent large, server-friendly dependencies from being unnecessarily shipped to the browser.
Watch from the beginning until 6:07. Focus on the core concept: why you should mark the smallest possible component as 'client' rather than an entire page. This idea of isolating client logic into 'leaf' components is a key pattern we will implement using TanStack's tools.
This principle of isolation is key. When you find a component that needs localStorage, don't make the entire page or layout client-only. Instead, extract the part that needs localStorage into its own smaller component and apply a client-only strategy just to that component.
3. Refactoring with TanStack Start's Execution Control APIs
Now let's look at the specific tools TanStack Start provides to implement these patterns. Unlike the "use client" directive, TanStack offers a more granular set of APIs for controlling code execution.
The Problem: Isomorphic by Default
As we covered in Module 1, TanStack Start's default behavior is isomorphic—code is included in both server and client bundles. This is powerful but also the source of our current challenge. An unguarded call to localStorage.getItem('theme') will crash the server.
Let's explore the solutions, from the simplest to the most flexible.
Solution 1: The <ClientOnly> Component
The most straightforward tool is the <ClientOnly> component, which is part of TanStack Router. It prevents its children from rendering on the server.
- On the server: It renders a specified
fallbackprop ornull. - On the client: After the application hydrates, it renders its
children.
This is perfect for components that are not essential for the initial paint or SEO, such as a complex data visualization or an interactive widget.
import { ClientOnly } from '@tanstack/react-router'
import { MyFancyChart } from 'some-chart-library'
function DashboardRoute() {
return (
<div>
<h1>Server-Rendered Header</h1>
<p>This content is part of the initial HTML.</p>
{/* The chart will only render on the client after hydration. */}
<ClientOnly fallback={<div className="chart-placeholder">Loading chart...</div>}>
{() => <MyFancyChart />}
</ClientOnly>
</div>
)
}
You can find more details on this in the "Client-Only Execution" section of the Execution Model documentation we looked at earlier (3380f).
Solution 2: Handling Imports with React.lazy and Dynamic import()
What if the import 'some-chart-library' statement itself crashes the server? This happens when a library executes browser-specific code at the top level of the module. In this case, <ClientOnly> is not enough because the import at the top of your file runs before the component even tries to render.
The solution is to use dynamic import() combined with React.lazy. This defers loading the component's code until it's actually rendered on the client.
import { ClientOnly } from '@tanstack/react-router'
import React, { Suspense } from 'react'
// Dynamically import the component. It will only be fetched by the browser.
const LazyMyFancyChart = React.lazy(() =>
import('../components/MyFancyChart')
);
function DashboardRoute() {
return (
<div>
<h1>Server-Rendered Header</h1>
<ClientOnly fallback={<div className="chart-placeholder">Loading chart...</div>}>
{() => (
// Suspense is required by React.lazy to show a fallback while the component code is loading.
<Suspense fallback={<div className="chart-placeholder">Loading component...</div>}>
<LazyMyFancyChart />
</Suspense>
)}
</ClientOnly>
</div>
)
}
This pattern is a standard technique in modern web development for handling SSR compatibility, not just in TanStack Start but across the ecosystem (as shown in the VitePress docs, e7b74).
Solution 3: Granular Control with Isomorphic Functions
Sometimes you don't need to isolate an entire component, but just a single function. For this, TanStack Start provides powerful function-level controls. This video provides an excellent overview of the concept.
TanStack Changed How I Think About Functions.
This video by Youssef Benlemlih explores TanStack Start's function-based execution model, focusing on the powerful createIsomorphicFunction.
Watch from 01:18 to 04:45. This will introduce you to createClientOnlyFn and, more importantly, createIsomorphicFn, which allows you to provide different implementations of the same function for the server and client.
To summarize the key APIs from the video and the official docs:
-
createClientOnlyFn(fn): Creates a function that will only run on the client. If it's ever called on the server, it will throw an error. This is useful for enforcing that a function likesaveToLocalStorageis never accidentally used in server code.import { createClientOnlyFn } from '@tanstack/react-start'; const saveToLocalStorage = createClientOnlyFn((key: string, value: string) => { window.localStorage.setItem(key, value); }); -
createIsomorphicFn(): This is the most flexible tool. It allows you to define two different implementations for a single function: one for the server and one for the client. The bundler (Vinxi) is smart enough to only include the relevant code in each bundle (tree-shaking).This is perfect for creating utilities that can be safely called anywhere, but behave differently depending on the environment.
import { createIsomorphicFn } from '@tanstack/react-start'; // This function can be called from any component or loader, server or client. export const getSessionId = createIsomorphicFn() .client(() => { // On the client, get it from a cookie. return document.cookie.match(/sessionId=([^;]+)/)?.[1]; }) .server((ctx) => { // On the server, get it from the request headers. // `ctx` provides access to the server request context. return ctx.request.headers.get('cookie')?.match(/sessionId=([^;]+)/)?.[1]; });
Your Refactoring Strategy
As you begin migrating your SPA, apply this systematic approach:
- Identify: Scan your components and utilities for direct use of browser APIs or for imports of libraries known to be client-only (e.g., charting, analytics).
- Isolate: Refactor the client-dependent logic into the smallest possible unit—either its own component or a dedicated utility function. Follow the "leaves of the tree" principle.
- Implement: Choose the right TanStack Start tool for the job:
- Entire Component: Is the whole component non-essential for SSR? Wrap it in
<ClientOnly>. If its import is the problem, combine<ClientOnly>withReact.lazyand dynamicimport(). - Utility Function: Does a function need to access a browser API? Wrap it with
createClientOnlyFn. - Dual Behavior: Do you need a function that works in both environments but does different things? Use
createIsomorphicFn.
- Entire Component: Is the whole component non-essential for SSR? Wrap it in
Conclusion
In this lesson, we've established a foundational skill for any SSR migration: managing the boundary between server and client execution.
Key Takeaways:
- Identify: The first step is to find any code that relies on browser-only APIs (
window,document, etc.) or libraries that use them. - Isolate: Extract client-dependent logic into small, focused components or functions to avoid compromising your application's SSR performance.
- Refactor: Use TanStack Start's toolkit—
<ClientOnly>,createClientOnlyFn, andcreateIsomorphicFn—to ensure code runs only in the appropriate environment. Dynamicimport()withReact.lazyis your solution for libraries that are not SSR-safe on import.
In our next lesson, we will continue the migration process by tackling another core task: refactoring data fetching from client-side useEffect hooks to server-side route loaders. This will build directly on today's concepts, as loaders themselves are isomorphic and require careful management of their execution context.
Can't find a good explanation? Sign up and we'll make it for you
Sign up