SSR Is Not a Toggle: Why Enabling Server-Side Rendering Breaks Your Vue App
8 min read
You read the docs. “Enable SSR with a single config change.” You flip the flag. Nuxt, Inertia, Quasar, whatever your stack, they all make it sound like a free upgrade. Better SEO, faster first paint, improved Core Web Vitals. One click.
You deploy. You open the page. It’s white. Completely blank.
SSR is not a toggle. It’s an architectural contract. And if your code wasn’t written with that contract in mind, enabling server-side rendering doesn’t upgrade your app. It breaks it.
The Promise vs. The Reality
The tooling has gotten incredible. Nuxt gives you SSR out of the box. Inertia.js added SSR support with a simple server entry point. Vite’s SSR build pipeline handles the bundling. From an infrastructure standpoint, going from client-only to server-rendered has genuinely never been easier.
But infrastructure is only half the equation. Your components also need to be SSR-compatible. And most codebases written as SPAs are full of patterns that silently break when the same code runs on a server.
The frustrating part? These breaks don’t show up during development. They don’t show up during client-side navigation. They only appear on hard refreshes in production, when the server actually renders the page and the client tries to hydrate it. By then, you’ve already shipped.
How Hydration Works (and Fails)
When SSR is enabled, your Vue app runs twice for every page load. First on the server, which generates a complete HTML string the browser can render immediately. Then on the client, where Vue “hydrates” the existing DOM by walking it node-by-node and attaching reactivity and event listeners.
Hydration is not a re-render. Vue doesn’t throw away the server HTML and start over. It expects the DOM to already match what it would have rendered. If everything lines up, Vue reuses the existing nodes. If something doesn’t match, you get a hydration mismatch. Depending on severity, Vue might patch it with a warning, or abandon hydration entirely, leaving you with a dead page.
The rule is simple: the server render and the client’s first render must produce identical HTML. Every pattern that violates this rule is a ticking time bomb once you enable SSR.
The Patterns That Break
Here’s where the “just enable SSR” fantasy falls apart. These are patterns that work perfectly fine in client-only SPAs but cause immediate hydration failures when SSR enters the picture.
Math.random(), new Date(), and Friends
Any value that produces different results each time it runs will differ between server and client. They run at different times, on different machines.
// Works fine as an SPA, breaks with SSR
const floatingIcons = computed(() => {
return Array.from({ length: 12 }, (_, i) => ({
left: `${Math.random() * 90 + 5}%`,
top: `${Math.random() * 80 + 10}%`,
}));
});
const currentYear = new Date().getFullYear();
The server renders left: 47.3%. The client renders left: 82.1%. Mismatch. Page dies.
SSR-safe approach: Move non-deterministic values into onMounted(), which only runs on the client after hydration completes:
const floatingIcons = ref([]);
onMounted(() => {
floatingIcons.value = Array.from({ length: 12 }, (_, i) => ({
left: `${Math.random() * 90 + 5}%`,
top: `${Math.random() * 80 + 10}%`,
}));
});
For shared values like the current year, pass them from the server as a prop so both sides agree.
Browser APIs at Setup Level
window, document, localStorage, navigator: none of these exist on the server. In an SPA, every line of your component runs in the browser. With SSR, setup-level code runs on the server first.
// SPA-only code that explodes with SSR
const width = window.innerWidth;
const token = localStorage.getItem('auth_token');
const isMobile = navigator.userAgent.includes('Mobile');
SSR-safe approach: Gate browser APIs behind onMounted():
const width = ref(0);
const token = ref('');
onMounted(() => {
width.value = window.innerWidth;
token.value = localStorage.getItem('auth_token') ?? '';
});
This is probably the most common category of SSR breakage. Entire npm packages assume a browser environment and will crash your server process if imported at the top level.
Teleport Components
Toast notifications, modals, floating widgets, loading overlays. Anything using <Teleport to="body"> is an SSR hydration minefield.
The SSR output contains teleport placeholder comments where the component would be. The client expects either the teleported content or nothing. The DOM structures don’t align.
SSR-safe approach: Defer teleported components to client-only rendering:
const isMounted = ref(false);
onMounted(() => { isMounted.value = true; });
<Toaster v-if="isMounted" position="top-center" :rich-colors="true" />
<FeedbackWidget v-if="isMounted" />
<LoadingOverlay v-if="isMounted" :show="isLoading" />
Both server and client render a <!--v-if--> comment during the first pass. They match. After hydration, the components mount normally. This pattern applies to any library that uses Teleport internally, including vue-sonner and most modal/dialog packages.
Links Without Explicit Attributes
In Inertia.js applications, the <Link> component without an href defaults to / during SSR but resolves to the current page URL on the client. A subtle difference in a single HTML attribute is enough to trigger a mismatch.
<!-- SSR renders href="/" while client renders href="/dashboard" -->
<Link class="flex items-center">Browse Job Posts</Link>
SSR-safe approach: Always provide an explicit href:
<Link :href="jobPostsIndex().url" class="flex items-center">
Browse Job Posts
</Link>
In an SPA, implicit defaults don’t matter because there’s no server-rendered HTML to compare against. With SSR, every attribute in every element is part of the contract.
Media Queries That Drive Conditional Rendering
Composables like useMediaQuery return false during SSR because there’s no browser window to measure. If the result controls v-if/v-else branching, the server renders the desktop layout while a mobile client expects the mobile layout. Mismatch.
// Server always renders as "not mobile"
const isMobile = useMediaQuery('(max-width: 768px)');
SSR-safe approach: Defer JavaScript-driven responsive logic to onMounted(), or use CSS-based responsive design instead, which doesn’t change the DOM structure.
The Real Cost of “Just Enable It”
The patterns above aren’t edge cases. They’re standard practices in client-side Vue development. Math.random() for layout variety. localStorage for auth state. useMediaQuery for responsive behavior. <Teleport> for overlays. These are in almost every non-trivial SPA.
That’s why “enable SSR” is never actually one click. The infrastructure change is one click. The codebase preparation is a full audit.
Every component needs to be evaluated: does it access browser APIs? Does it use non-deterministic values in its template? Does it teleport content? Does it rely on implicit defaults that differ between server and client?
Writing SSR-Ready Code From Day One
If there’s any chance your app might use SSR in the future, or if you’re building a component library, follow these rules from the start:
-
No browser APIs at setup level.
window,document,localStorage,navigatorbelong inonMounted()or event handlers. Always. -
No non-deterministic values in render.
Math.random(),new Date(),crypto.randomUUID()must be generated inonMounted()or passed as deterministic props from the server. -
Defer Teleport components. Anything using
<Teleport>should be wrapped inv-if="isMounted"to skip the server render entirely. -
Make every attribute explicit. No relying on implicit defaults for
href,src, or any attribute that might resolve differently on server vs client. -
Use CSS for responsive layout. Reserve JavaScript-driven responsive logic for post-mount behavior, not initial render structure.
These rules cost almost nothing to follow when you’re writing the code. Retrofitting them into an existing SPA? That’s where the real work lives.
Debugging When Things Go Wrong
When you’ve enabled SSR and hit a blank page, Vue can tell you exactly what went wrong. Add this to your vite.config.ts temporarily:
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true,
},
Rebuild your SSR bundle, restart the server, and check the browser console. Vue will report the exact node that mismatched, what the server produced, and what the client expected. Remove the flag before deploying to production, as it adds overhead.
SSR Is Worth It, But It’s Not Free
Server-side rendering delivers real benefits. Faster first contentful paint, better SEO, improved perceived performance, better accessibility for users on slow connections. These are meaningful advantages.
But the marketing around modern SSR tooling creates a dangerous illusion. The infrastructure is a one-click setup. The code is not. SSR is an architectural decision that affects how every component in your application is written. Treating it as a simple toggle is how you end up with a production app that goes blank on refresh while everything looks perfect in development.
The teams that succeed with SSR are the ones that understand this upfront. They write SSR-safe code from the beginning, or they budget the time for a proper audit before flipping the switch. There are no shortcuts here.
Planning to Enable SSR?
If you’re considering server-side rendering for your Vue application and want to avoid the blank-page surprises, . I help teams prepare their codebases for SSR and fix hydration issues so the transition is smooth.