B Blengi docs

Operate

Inertia SSR & PM2 setup

Pitchbar's admin and marketing pages render through Inertia v3 + React 19. By default Inertia ships the page state as a JSON payload inside a <script data-page="app"> tag; React then hydrates that into DOM on first paint. Crawlers (Pitchbar's own CrawlPageJob, Google, Bing, social-card scrapers) don't execute JS β€” they see the empty shell and bail at the script tag.

Server-side rendering (SSR) fixes this by pre-rendering each Inertia page in a Node process before Laravel returns the HTML. Crawlers, SEO bots, and the first-paint of slow connections all see fully-rendered markup.

How it's wired

  • resources/js/ssr.tsx β€” Vite SSR entry. Mirrors resources/js/app.tsx but renders via react-dom/server instead of react-dom/client.
  • bootstrap/ssr/ssr.js β€” built artifact, committed to the repo alongside public/build/* so the deploy host never has to run npm run build:ssr.
  • config/inertia.php β€” ssr.enabled = true, ssr.url = http://127.0.0.1:13714. Octane / FrankenPHP proxies each Inertia response through this URL.
  • ecosystem.config.cjs β€” declares the pitchbar-ssr PM2 process alongside pitchbar-queue.

One-time PM2 setup (per server, ever)

Pitchbar already uses PM2 for the queue worker. Add the SSR process to the same daemon:

cd /var/www/html
pm2 reload ecosystem.config.cjs
pm2 save

pm2 reload picks up the new pitchbar-ssr entry and starts it. pm2 save snapshots the process list so PM2 restores it on server reboot (assuming pm2 startup was run when the queue worker was first installed β€” if not, run that once too).

Verify the process is running:

pm2 status pitchbar-ssr
# expect: status=online, watching=enabled
curl -s http://127.0.0.1:13714/health
# expect: HTTP 200

Every future deploy

Just git pull. Nothing else.

The PM2 entry watches bootstrap/ssr/ssr.js for mtime changes. When git pull replaces the committed bundle, PM2 restarts the Node process automatically. No pm2 reload, no manual restart, no SSH-and-touch-something step.

How to verify SSR is actually rendering pages

curl -sk -A "Mozilla/5.0" https://YOUR-DOMAIN/integrations \
    | grep -oE 'data-server-rendered'

If you see data-server-rendered printed, SSR is working β€” Inertia stamps that attribute on the root <div> only when the Node renderer answered successfully. If you see nothing, the Node process either isn't running or is crashing per request β€” check storage/logs/pm2-ssr-error.log.

Graceful fallback when SSR is down

If pitchbar-ssr stops responding (Node process died, port 13714 closed), Inertia falls back to client-side rendering automatically. Marketing pages will still load for browsers β€” but crawlers and social bots will go back to seeing the JS shell. PM2's autorestart + max_restarts: 50 recovers from process-level crashes without intervention. If the process refuses to start at all, the error log is the first place to look.

When NOT to commit a stale bundle

Whenever you edit a file under resources/js/, resources/css/, or any Inertia page, rebuild and re-stage the SSR bundle in the same commit:

npm run build
npm run build:ssr
git add public/build bootstrap/ssr resources/

Forgetting this means the client-side bundle has new code but the SSR bundle still serves the old version β€” first-paint markup diverges from the eventual hydrated DOM and React 19 will throw a hydration mismatch warning. The Architecture page has more on the repo-as-deploy-artifact rule.

Hydration safety β€” never read client-only state during render

The other way to get a React hydration mismatch (error #418) has nothing to do with a stale bundle: a component that reads client-only browser state during render. The SSR Node process has no window, localStorage, Notification, or matchMedia, so it renders one thing; the browser reads the real value on first paint and renders another. The two disagree and React throws away the server tree, re-rendering the whole root on the client β€” which silently defeats SSR and can reset component state (e.g. a form clearing after save).

The rule: the first client render (hydration) must match the server HTML. Read client-only state after mount, not during render:

  • Seed useState from a value the server also produces β€” a prop, a cookie echoed by HandleInertiaRequests, or a neutral default β€” then reconcile from localStorage / Notification.permission in a useEffect.
  • For values derived from window.matchMedia (theme, mobile breakpoint), route them through useSyncExternalStore with a deterministic getServerSnapshot. See resources/js/hooks/use-mobile.tsx for the canonical pattern.

To reproduce a suspected mismatch locally, serve the production-style SSR bundle and watch the browser console:

php artisan inertia:start-ssr    # serves the committed bundle on :13714
# load an admin page in the browser; a red
# "Minified React error #418" in the console means the hydration
# render diverged from the SSR HTML. Diff the two by fetch()-ing the
# same URL (that returns the SSR HTML) against the live DOM.

Local development

During npm run dev, the Inertia Vite plugin handles SSR inline without a separate Node process. PM2 is for production only. To smoke-test SSR locally against the production-style bundle:

npm run build:ssr
node bootstrap/ssr/ssr.js &
curl -sk https://pitchbar.test/integrations | grep data-server-rendered