Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our course on migrating your React SPA to a modern SSR application with Tanstack Start.
In our last lesson, we completed the data-flow picture by transforming client-side mutations into server functions, ensuring our client-side cache stays in sync. Our application can now read and write data in an SSR-friendly way.
However, a major hurdle in any SPA-to-SSR migration is handling code that is fundamentally designed to run only in a browser. Many components, and especially third-party libraries, depend on browser-specific APIs like window, document, or localStorage. These do not exist in the server environment where your code first runs, leading to crashes.
This lesson directly addresses that challenge, focusing on the learning outcome: Refactor components with client-side dependencies to run conditionally or be isolated using dynamic imports with ssr: false. We will explore a toolkit of strategies, from simple conditional checks to more robust component isolation techniques, allowing you to successfully integrate client-dependent code into your server-rendered application.
1. The Isomorphic Challenge: When Code Runs Everywhere
A key concept in frameworks like Tanstack Start is that your code is often isomorphic—it's designed to run on both the server (during the initial request) and the client (during hydration and subsequent navigations).
This is powerful, but it's also the source of our problem. What happens when a component tries to access a browser-only API?
// A component that tries to access localStorage during render
function UserProfile() {
// This line will crash the app during server-side rendering!
const theme = localStorage.getItem('theme');
return (
<div className={theme}>
Welcome back!
</div>
);
}
When the server attempts to render this component, it will encounter localStorage, which is not defined in a Node.js or Bun environment. This will throw a ReferenceError and halt the entire rendering process.
To understand this isomorphic behavior in Tanstack Start, let's review a short explanation.
TanStack Start vs Next.js: Is It Really Better?
This video from Shruti Kapoor explains what 'isomorphic by default' means in Tanstack Start and demonstrates how loader functions can run in both environments. This context is crucial for understanding why we need to be careful about where our code executes.
Watch the section 'Understanding Isomorphism in TanStack Start' from 04:39 to 06:35. Pay attention to the demonstration of a loader running first on the server, then on the client.
Now that we've established the problem, let's explore the solutions.
2. Solution 1: Conditional Execution with useEffect
The most direct way to handle client-side logic is to ensure it only runs after the component has been mounted in the browser. The useEffect hook is the perfect tool for this.
You can guard your browser-specific code with a simple check: typeof window !== 'undefined'.
Let's fix our previous example:
import { useState, useEffect } from 'react';
function UserProfile() {
// Initialize state to a default value that works on the server.
const [theme, setTheme] = useState('light');
// Defer the client-side logic until after the component mounts.
useEffect(() => {
// This check is technically redundant inside useEffect, which only runs on the client,
// but it's excellent practice for clarity and safety.
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
setTheme(savedTheme);
}
}
}, []); // The empty dependency array ensures this runs only once on mount.
return (
<div className={theme}>
Welcome back!
</div>
);
}
This pattern works well for simple cases within a component. However, it doesn't solve the problem if an entire third-party library is client-only. The import statement itself might cause a crash if the library executes browser-specific code at the top level. For that, we need to isolate more aggressively.
3. Solution 2: Isolating Logic and Components
For more complex scenarios, you need to prevent client-only code from ever being included in the server's execution path. Tanstack Start and React provide several powerful mechanisms for this.
A. Abstracting Logic with createClientOnlyFunction
Tanstack Start offers a dedicated utility for creating functions that are guaranteed to run only on the client. This is ideal for abstracting away reusable browser API interactions.
TanStack Start vs Next.js: Is It Really Better?
Let's return to the video by Shruti Kapoor, which introduces this handy utility.
Watch the segment from 08:27 to 09:42. It demonstrates createClientOnlyFunction for accessing localStorage and also introduces createIsomorphicFunction for cases where you might want different behavior on server and client.
Using this, you can create a clean, reusable utility for accessing local storage:
app/lib/client-storage.ts
import { createClientOnlyFunction } from '@tanstack/start';
export const getItem = createClientOnlyFunction(
(key: string): string | null => {
// This code is now completely stripped from the server bundle.
return window.localStorage.getItem(key);
}
);
export const setItem = createClientOnlyFunction(
(key: string, value: string): void => {
window.localStorage.setItem(key, value);
}
);
Your component then becomes much cleaner, though you still need useEffect to avoid hydration mismatches (since getItem will return undefined during the server render).
B. Isolating Components with Dynamic Imports (React.lazy)
What if an entire component, like a complex charting library, is client-side only? The solution is to load it dynamically, only when it's needed on the client. React's built-in React.lazy and <Suspense> are the standard tools for this.
By wrapping a dynamic import in React.lazy, you tell React to load this component's code in a separate chunk. When you combine it with <Suspense>, you can render a fallback (like a loading spinner) while the component is being fetched.
import React, { Suspense } from 'react';
// Dynamically import the client-only component.
const ClientSideChart = React.lazy(() => import('../components/DataChart'));
function Dashboard() {
return (
<div>
<h1>Sales Dashboard</h1>
<p>Here are the latest trends:</p>
{/* The Suspense boundary will render the fallback on the server */}
{/* and on the client until the component code is loaded. */}
<Suspense fallback={<div>Loading chart...</div>}>
<ClientSideChart />
</Suspense>
</div>
);
}
On the server, React will see the <Suspense> boundary and render the fallback content in the initial HTML. The ClientSideChart component itself is never rendered on the server, thus avoiding any errors from its internal browser-dependent code.
C. Disabling SSR for an Entire Route
In some cases, a route may be so highly interactive and client-dependent (e.g., a complex editor or a dashboard behind a login) that server-rendering provides little benefit. For these scenarios, you can disable SSR for the entire route. This effectively turns that specific page into a traditional Single-Page Application.
You can achieve this by setting the ssr: false option in your route definition.
TanStack Start vs Next.js: Is It Really Better?
Both of the following video clips quickly demonstrate this powerful 'escape hatch'.
First, watch the clip from 'TanStack Start vs Next.js' from 12:24 to 13:57, which explains how setting ssr: false changes the rendering lifecycle to be client-only.
TanStack is Your New Favorite Framework
Next, watch this short clip from the CascadiaJS talk for another example.
Watch from 19:22 to 19:53 to see how ssr: false can be used to mix and match SSR and client-rendered pages within the same application.
Here's how you would implement it in your route file:
app/routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router';
import { DashboardComponent } from '~/components/Dashboard';
export const Route = createFileRoute('/dashboard')({
// The component to render for this route
component: DashboardComponent,
// This tells Tanstack Start to skip SSR for this route
ssr: false,
});
When a user navigates to /dashboard, the server will send a minimal HTML shell, and the entire DashboardComponent will be rendered on the client after hydration.
4. A Unified Refactoring Strategy
When migrating your SPA, you can use the following thought process to decide which technique to use:
- Identify the Dependency: Find the code that uses browser APIs (
window,document, etc.). - Assess the Scope:
- A small snippet? If it's just a few lines inside a component, use
useEffectand atypeof window !== 'undefined'check. - A reusable piece of logic? Abstract it into a
createClientOnlyFunction. - An entire component? If a self-contained component is client-only, wrap it in
React.lazyand<Suspense>. - An entire page? If the whole route is better off as a client-rendered experience, set
ssr: falsein the route definition.
- A small snippet? If it's just a few lines inside a component, use
This tiered approach allows you to preserve the benefits of SSR for most of your app while safely handling the parts that must run in the browser.
Conclusion
You now have a complete toolkit for handling client-side dependencies in an SSR application. By correctly identifying and isolating code that relies on browser APIs, you can successfully migrate even complex SPAs to Tanstack Start without sacrificing functionality.
Key Takeaways:
- Isomorphic code can cause
ReferenceErrorcrashes during SSR if it accesses browser-only APIs likewindoworlocalStorage. - For small, localized logic, use
useEffectto defer execution until the component mounts on the client. - For reusable client-side logic, use Tanstack Start's
createClientOnlyFunctionto create clean, server-safe utilities. - For entire components that are client-only, use
React.lazyand<Suspense>to prevent them from being rendered on the server. - For routes that are inherently client-side, you can use the
ssr: falseoption as a powerful escape hatch to disable SSR for that page entirely.
Next Up:
We've learned the techniques for isolating client-side code. In the next lesson, we'll apply this knowledge to a very common and practical problem: Assess third-party library dependencies for SSR compatibility and isolate incompatible libraries. We'll look at how to audit your package.json and develop strategies for making them work in an SSR world.