DEV Community

Cover image for Your Inertia SSR server is down and your site still returns 200
Datum Games
Datum Games

Posted on

Your Inertia SSR server is down and your site still returns 200

The site looked fine. Every page loaded, every blog post rendered, the styling was
right, nothing in the logs. What was wrong was that Search Console had crawled two
pages out of the entire sitemap, and the rest sat in Discovered – currently not
indexed
. Dozens of posts, almost nothing indexed, and no broken page anywhere to
point at.

The cause turned out to be a process that had stopped running. Not crashed loudly —
just stopped. And because of how Inertia handles that case, the only clients that
noticed were the ones we could not see.

Disclosure: I work on Bunfolio, a free portfolio-site
builder for freelancers. This happened to us, and the diagnosis is the useful part.

The failure mode

The stack is Laravel 12, Inertia.js and React 19, with server-side rendering enabled.
In that setup, php artisan inertia:start-ssr runs a small Node server (port 13714 by
default) that loads your built server bundle, bootstrap/ssr/ssr.js. On each request
Laravel POSTs the page object to it, gets back HTML, and the @inertia Blade
directive prints that HTML inside <div id="app">.

When that Node process is not answering, Laravel does not fail the request. It falls
back to client-side rendering: it emits an empty <div id="app" data-page="{…}">
with the page props serialised into the attribute, and ships the response with a 200.
Your browser downloads the JavaScript, reads data-page, renders the app, and
everything looks completely normal.

So the failure is invisible to exactly the people checking for it, and visible only
to clients that do not execute JavaScript. That is a decent chunk of the things you
care about: crawlers that do not render, link unfurlers, anything reading your page
with an HTTP library. Google does render JavaScript, but rendering is queued and
budgeted separately from crawling, and on a new site with essentially no inbound
links you should assume that budget is close to zero. An empty #app is a page with
no prose in it, and a page with no prose in it is not a page worth indexing.

The part that makes this a genuine trap rather than an ordinary outage: you cannot
detect it by looking at the site.
Uptime checks pass. Status codes are 200.
Screenshots are perfect. The failure only exists in the response body, and only in
the part of it that a browser immediately overwrites.

Why the process stops

Three ways we found to end up here, all of them quiet.

It was never supervised. Someone started inertia:start-ssr by hand over SSH to
test it. It works, the page renders, everyone moves on. The shell closes, the process
dies with it, and the app keeps serving 200s.

The server bundle throws at render time. Our own version of this: twenty-two
components called a bare global route(), which the @routes Blade directive
defines in the browser. Node has no such global, so every server render died with
route is not defined — and Inertia treated that the same way it treats a dead
process, by falling back to the client. The fix was a shim at the top of ssr.jsx:

import { route as routeFn } from 'ziggy-js';
import { Ziggy } from './ziggy';

const ziggyConfig = { ...Ziggy, url: process.env.APP_URL ?? Ziggy.url };

global.route = (name, params, absolute, config = ziggyConfig) =>
    routeFn(name, params, absolute, config);
Enter fullscreen mode Exit fullscreen mode

Taking the host from APP_URL at run time rather than from the value baked into
ziggy.js at build time matters too, otherwise the markup React hydrates against
does not match what the client would have produced.

It is running the wrong code. More on that below — it is the second trap and it
is worse than the first.

Detecting it properly

There is a built-in health check, and you should run it:

php artisan inertia:check-ssr    # must print: Inertia SSR server is running.
Enter fullscreen mode Exit fullscreen mode

But be clear about what it proves. It opens a connection to the configured SSR URL
and confirms something answers. It does not prove your public site is using that
process, that the bundle it loaded renders your pages without throwing, or that the
HTML reaching a crawler contains anything. A green check-ssr with an empty #app
in production is entirely possible, and is precisely the state we were in.

The real test is to be the crawler. Fetch the page over HTTP, find <div id="app">,
strip the scripts and tags out of everything after it, and count what is left:

for u in / /blog /blog/teaching-portfolio /tools /case-studies; do
  printf '%-32s ' "$u"
  curl -sS "https://example.com$u" \
    | python3 -c "
import re,sys
h=sys.stdin.read()
m=re.search(r'id=\"app\"[^>]*>',h)
inner=h[m.end():] if m else ''
t=re.sub(r'<[^>]+>',' ',re.sub(r'<script.*?</script>','',inner,flags=re.S))
print(len(re.sub(r'\s+',' ',t).strip()), 'chars rendered')"
done
Enter fullscreen mode Exit fullscreen mode

Every line should report thousands of characters. A 0 means SSR is not reaching
that page, and the deploy has failed for search purposes even though the site works
perfectly in a browser.

Stripping <script> blocks before counting is the load-bearing detail. Without it
you are counting the serialised data-page JSON, which is large and present in
both the working and the broken case — so the check would pass either way. That is
the same reason "view source and eyeball it" does not work: the broken response is
not empty, it is full of JSON that looks reassuringly like your content.

For the authoritative version, use Test live URL in Search Console and open View
tested page → HTML
. That is Google telling you what Google got.

The second trap: a stale bundle

This one cost more time than the outage, because nothing at all appears wrong.

inertia:start-ssr reads bootstrap/ssr/ssr.js once, at boot. If you rebuild
assets and do not restart the process, it carries on rendering the previous bundle
indefinitely. Your browser fetches the new client build and hydrates over the old
server HTML, so the page you are looking at is correct and current. Meanwhile curl
— and every crawler — gets markup from whatever your code looked like at the last
restart.

The page is not broken. It is just old. You can stare at a diff for an hour
wondering why a change you can see in your browser is not in the HTML, and there is
no error anywhere to find. Restart the SSR process after every build, locally as well
as in production.

A deploy that cannot skip the restart

The shape that works is boring, and the ordering matters — build, then restart, then
verify:

git pull --ff-only origin main
composer install --no-dev --optimize-autoloader
npm ci --ignore-scripts
npm run build:ssr                      # builds client AND server bundles
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
sudo supervisorctl restart inertia-ssr # the step that gets missed
Enter fullscreen mode Exit fullscreen mode

Our build:ssr also regenerates the Ziggy route file first, so the server bundle
cannot drift from routes/web.php:

"build:ssr": "php artisan ziggy:generate resources/js/ziggy.js && vite build && vite build --ssr"
Enter fullscreen mode Exit fullscreen mode

A route added in PHP but missing from the bundle's route table is another way to
throw inside the server render and silently fall back, so tying the two together in
one command removes a whole category of this bug.

And the process must be supervised. Started by hand it dies with your shell;
supervised it comes back on its own after a crash, a deploy or a reboot:

[program:inertia-ssr]
command=/usr/bin/php /var/www/example/artisan inertia:start-ssr
directory=/var/www/example
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/inertia-ssr.log
stopwaitsecs=10
Enter fullscreen mode Exit fullscreen mode

autorestart=true is doing real work here. A server render that throws can take the
Node process down; without this you get one bad request followed by permanent silent
client-side rendering.

Two smaller things worth stealing

Pin SSR off in your test suite. We set INERTIA_SSR_ENABLED=false in
phpunit.xml, so the suite never depends on a Node process being up. A test run that
fails because you forgot to start the SSR server teaches you to ignore the failure,
which is the opposite of useful.

Own your meta server-side anyway. We render title, description, canonical, OG and
JSON-LD from controller view data in the Blade layout rather than from React's
<Head>. That started as a workaround from before SSR existed, and it is worth
keeping: it means a broken SSR server costs you the body copy but not the metadata.
It also means you have to filter the title React collects out of the SSR response, or
you ship two <title> elements per page:

head: rendered.head.filter((tag) => !tag.startsWith('<title')),
Enter fullscreen mode Exit fullscreen mode

The check to actually keep

If you take one thing: inertia:check-ssr answers "is the process alive". The curl
loop answers "does prose reach a client that does not run JavaScript". Only the
second question is the one your search traffic depends on, and it is the only one
that catches a stale bundle as well as a dead process.

Put it at the end of your deploy script, where skipping it takes effort.

Top comments (2)

Collapse
 
beusebiu profile image
Eusebiu Balan

The recovery is slower than the fix, and that caught me out. Once pages land in Discovered and not indexed, restoring SSR does not pull them back on its own. They sit there until something makes Google crawl again, which on a small site means resubmitting the sitemap, a few manual index requests, then weeks of waiting.

Watch the gap between your sitemap count and your indexed count in Search Console. It moves long before traffic does.

Collapse
 
datum_games profile image
Datum Games

Completely agree, and the sitemap-vs-indexed gap is the right thing to watch — it moves well before traffic does.

One distinction that saved us a lot of wasted effort: "Discovered – currently not indexed" and "URL is unknown to Google" look similar in the report but aren't the same failure. Unknown is a discovery problem, and a manual request genuinely fixes it. Discovered-not-indexed means Google already has the URL and chose not to crawl it — a request nudges that one page, but the cause is crawl priority, so it recurs unless internal links or authority change. The two pages we found in that state each had exactly one internal link pointing at them besides the blog index.

Two practical notes for anyone doing the manual pass:

  • Inspection costs no quota, requests do. We inspected 30 URLs expecting a backlog and found 28 already indexed. Requesting blind would have burned most of a day's allowance on pages that needed nothing.
  • The ~9/day limit runs on Google's day, not yours. We're on UTC+5 and lost two requests to refusals just after local midnight, because Search Console still considered it the previous Pacific day.