Stu Mason
Stu Mason
Guide

Converting a Laravel/Inertia App to SSR: The Real Problems Nobody Tells You About

Stuart Mason10 min read

SSR isn't just a config flag. Here's every problem I hit converting 396 pages to server-side rendering — window.location crashes, duplicate meta tags, and the SEO gotcha that nearly broke every page.

I shared an article on Twitter last week and the preview card showed my site's generic description. No title, no image, no excerpt — just "Software engineering consultancy." Every page on the site had the same problem. The OG tags were there in my React components, but crawlers weren't seeing them because they don't run JavaScript.

The fix was server-side rendering. It took 5 commits, touched 16 components and 12 controllers, and now all 396 pages render their HTML on the server before hitting the browser. The SSR part was straightforward. Everything else was not.

Why SSR matters for Inertia apps

Inertia apps are SPAs. The server sends a minimal Blade template with a JSON payload, and React (or Vue) renders the page client-side. This works fine for users with browsers, but social media crawlers and search bots don't execute JavaScript. They see the raw HTML — which means they only see whatever's in your Blade template.

If you're using @inertiaHead to inject meta tags from your React <Head> components, those tags only exist after JavaScript runs. Crawlers never see them. Your carefully crafted OG titles and descriptions are invisible to Twitter, LinkedIn, Facebook, and most search engine preview generators.

The fix is SSR. With SSR enabled, Inertia renders your React components on the server using Node.js, injects the resulting HTML into the Blade template, and sends fully-rendered markup to the client. Crawlers get real content. Social cards work. SEO improves.

Step 1: Enable the SSR build

The first change is the Dockerfile. You need to build the SSR bundle and copy it into your production image.

# Build stage - add SSR build
ENV DOCKER_BUILD=true
RUN npm run build:ssr

# Production stage - copy the SSR bundle
COPY --from=frontend-build /app/public/build ./public/build
COPY --from=frontend-build /app/bootstrap/ssr ./bootstrap/ssr

The build:ssr command builds both the client bundle and the SSR bundle in one step. Initially I had npm run build && npm run build:ssr — the separate client build is redundant.

You also need to point Inertia at the bundle. In config/inertia.php:

'ssr' => [
    'enabled' => env('INERTIA_SSR_ENABLED', false),
    'url' => 'http://127.0.0.1:13714',
    'bundle' => base_path('bootstrap/ssr/ssr.js'),
],

Two things caught me here. First, the bundle extension: Vite outputs ssr.js, not ssr.mjs. I initially had .mjs and the process wouldn't start. Second, I control SSR via an environment variable rather than hardcoding true — this lets me keep it off locally and enable it only in production.

Step 2: Run the SSR process

SSR needs a long-running Node.js process to handle render requests. In production I use supervisord to manage it alongside PHP-FPM, Horizon, and the scheduler.

[program:ssr]
command=/bin/sh -c "while [ ! -f /var/www/html/bootstrap/cache/config.php ]; do sleep 1; done && /usr/local/bin/php /var/www/html/artisan inertia:start-ssr"
user=www-data
autostart=true
autorestart=true
priority=30
startsecs=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

The shell wrapper with the while loop is important. The SSR process needs the config cache to exist before it can start. In a Docker container, the config cache is generated during the entrypoint script. If supervisord starts the SSR process before the cache exists, it fails immediately and enters a restart loop. The while loop waits for the cache file, then starts the process cleanly.

The startsecs=5 tells supervisord to wait 5 seconds before considering the process "started." This prevents false-positive restarts during the initial warmup.

The process listens on port 13714 by default. When Inertia receives a request, it sends the page component and props to this Node process, gets back rendered HTML, and injects it into the Blade template.

Step 3: The window.location crash

This is where the real work started.

With SSR enabled, I deployed and immediately got crashes:

ReferenceError: window is not defined

The SSR process runs in Node.js, not a browser. Node doesn't have window, document, localStorage, or any browser globals. Every component that referenced window.location.origin to build URLs — for OG tags, canonical links, JSON-LD structured data — crashed the entire SSR render.

Sixteen components were affected. The fix was two-part.

First, share the app URL from the server. In HandleInertiaRequests.php:

public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'name' => config('app.name'),
        'appUrl' => config('app.url'),
        // ...
    ];
}

Then in every component, replace window.location.origin with the shared prop:

// Before — crashes in SSR
const ogImage = `${window.location.origin}/og-image/article/${article.slug}`;

// After — works everywhere
const { appUrl } = usePage<SharedData>().props;
const ogImage = `${appUrl}/og-image/article/${article.slug}`;

This pattern applies everywhere: JSON-LD structured data, canonical URLs, Open Graph image URLs, any absolute URL construction. Every window.location.origin became appUrl. Every window.location.href became unnecessary since the server already knows the current URL.

The net result across 16 components was -210 lines removed. Most of those lines were OG meta tags in React <Head> components that shouldn't have been there in the first place (more on that next).

Step 4: The duplicate meta tag trap

With SSR enabled, I had a new problem: duplicate meta tags.

The Blade template (app.blade.php) renders OG tags from Inertia page props:

@php
    $ogTitle = $page['props']['ogTitle'] ?? 'Stu Mason - Software Engineering Consultancy';
    $ogDescription = $page['props']['ogDescription'] ?? 'Software engineering consultancy...';
    $ogImage = $page['props']['ogImage'] ?? url('/og-image/default');
    $ogType = $page['props']['ogType'] ?? 'website';
@endphp
<meta property="og:title" content="{{ $ogTitle }}">
<meta property="og:description" content="{{ $ogDescription }}">
<meta property="og:image" content="{{ $ogImage }}">
<meta property="og:type" content="{{ $ogType }}">

But the React components were also rendering OG tags inside <Head>:

<Head title={article.title}>
    <meta property="og:title" content={article.title} />
    <meta property="og:description" content={metaDescription} />
    <meta property="og:type" content="article" />
    <meta property="og:url" content={`${window.location.origin}/articles/${article.slug}`} />
    <meta property="og:image" content={ogImage} />
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:title" content={article.title} />
    <meta name="twitter:description" content={metaDescription} />
    <meta name="twitter:image" content={ogImage} />
    <link rel="canonical" href={`${window.location.origin}/articles/${article.slug}`} />
</Head>

With SSR, both the Blade template and the React component render server-side. That means every OG tag appears twice in the HTML. Crawlers see duplicate, potentially conflicting metadata.

The fix: remove the OG tags from React entirely and let Blade handle it. The React <Head> component should only set the page title and basic meta description:

<Head title={article.title}>
    <meta name="description" content={metaDescription} />
</Head>

That 16-line block collapsed to 2 lines. Multiply that across every page component and you get a significant cleanup.

Step 5: The controller prop cascade

Here's the gotcha that bit me. After removing OG tags from React components, I checked a shared article preview and saw... the site defaults again. Every page was showing "Stu Mason - Software Engineering Consultancy" because the Blade template was falling back to defaults.

The Blade template consumes $page['props']['ogTitle'], $page['props']['ogDescription'], etc. If those props aren't passed from the controller, the defaults kick in. Removing the React-side OG tags doesn't help if you never added controller-side props.

This meant updating 12 controllers to pass OG metadata:

// ArticleController
return Inertia::render('articles/show', [
    'article' => $dto,
    'ogTitle' => $article->title,
    'ogDescription' => $article->excerpt ?? Str::limit($article->content, 160),
    'ogImage' => $article->cover_image
        ? url($article->cover_image)
        : url("/og-image/article/{$article->slug}"),
    'ogType' => 'article',
]);

// ProfileController
return Inertia::render('profile/show', [
    'user' => $profileData,
    'ogTitle' => "{$user->name} (@{$user->username}) - Engineering Profile",
    'ogDescription' => "{$user->name} - AI Augmented Engineering...",
    'ogImage' => url("/og-image/profile/{$user->username}"),
    'ogType' => 'profile',
]);

// FeedController (homepage)
return Inertia::render('feed/index', [
    // ... page data
    'ogTitle' => 'Stu Mason - Full-Stack Developer UK',
    'ogDescription' => 'Full-stack developer based in Kent, UK...',
]);

Every controller that renders an Inertia page needs to pass at least ogTitle and ogDescription. Pages with specific images get ogImage. Pages with a non-default type get ogType. The Blade template handles the rest with sensible fallbacks.

Step 6: vite.config.ts

The Vite config needed two changes for SSR compatibility.

First, wrapping the config in defineConfig(({ isSsrBuild }) => ...) to detect SSR builds. Manual chunks (code splitting) breaks the SSR bundle — the Node process needs a single entry point, not split chunks. The fix is to conditionally skip manualChunks for SSR builds:

export default defineConfig(({ isSsrBuild }) => ({
    // ...
    build: {
        rollupOptions: {
            output: {
                ...(!isSsrBuild && {
                    manualChunks: {
                        'vendor-react': ['react', 'react-dom'],
                        'vendor-inertia': ['@inertiajs/react'],
                        'vendor-ui': ['sonner', 'lucide-react', 'date-fns'],
                        'vendor-echo': ['laravel-echo', 'pusher-js'],
                    },
                }),
            },
        },
    },
}));

Second, adding ssr: { noExternal: true } to bundle all dependencies into the SSR output:

ssr: {
    noExternal: true,
},

Without this, the SSR bundle tries to import dependencies at runtime using Node's module resolution. In a Docker container where node_modules doesn't exist in production (it's only in the build stage), every import fails. noExternal: true tells Vite to bundle everything into a single self-contained file.

The client-side entry point also changes from createRoot to hydrateRoot:

// Before — client-side rendering
import { createRoot } from 'react-dom/client';
const root = createRoot(el);
root.render(<App {...props} />);

// After — hydrating server-rendered HTML
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(el, <App {...props} />);

hydrateRoot tells React that the DOM already has server-rendered HTML. Instead of creating new DOM nodes, React attaches event listeners to the existing markup. This is faster for the user and avoids a flash of re-rendered content.

Validation

Once deployed, I verified SSR was working with a few checks.

Curl the page and look for actual HTML content (not just the Inertia JSON payload):

curl -s https://yoursite.com/articles/some-article | grep "og:title"

If SSR is working, you'll see the <meta property="og:title" content="Your Article Title"> tag with real content. Without SSR, you'd see the Blade default or nothing.

Check the Twitter Card Validator at cards-dev.twitter.com — paste a URL and see if the preview shows the correct title, description, and image.

Check container logs for the SSR process:

# If using Coolify/Docker
docker logs <container> 2>&1 | grep "ssr\|SSR\|13714"

You should see the SSR process start up and begin handling requests. If you see restart loops or ENOENT errors, the bundle path or config cache timing is wrong.

Common gotchas

Beyond window.location, there are other browser APIs that will crash SSR:

  • localStorage / sessionStorage — wrap in typeof window !== 'undefined' guards, or move the logic into a useEffect (which only runs client-side)
  • document.querySelector — same treatment. If you're doing DOM manipulation, it belongs in useEffect
  • IntersectionObserver — only instantiate inside useEffect
  • navigator — user agent detection, clipboard API, etc. All browser-only
  • window.addEventListener — event listeners for scroll, resize, etc. Must be in useEffect with cleanup

The general rule: if it touches the DOM or browser APIs, it needs to be behind a typeof window !== 'undefined' check or inside a useEffect. With SSR, your component code runs in two environments — Node for the initial render and the browser for hydration. Only code that works in both environments should be at the top level.

Summary

Five commits. Three config files. Sixteen components refactored. Twelve controllers updated. 396 pages now render server-side.

The actual SSR enablement — the Dockerfile, supervisord, config — took maybe 20% of the effort. The other 80% was cleaning up code that assumed it was running in a browser. Every window.location.origin, every inline OG tag in React, every missing controller prop.

The hard part isn't enabling SSR. It's discovering how many implicit browser assumptions are scattered through your codebase, and methodically fixing every one of them.

If you're running a Laravel/Inertia app and care about social sharing or SEO, SSR isn't optional — it's the only way to get your meta tags in front of crawlers. Just budget time for the cleanup. The config is the easy part.

Get the Friday email

What I shipped this week, what I learned, one useful thing.

No spam. Unsubscribe anytime. Privacy policy.