When I started integrating logical routes into Vue and Inertia SSR, I thought the hardest part was already done. Laravel knew the routes, Ziggy worked in the browser, and the frontend build was clean.
Then SSR started failing on routes that worked perfectly after the page loaded in the browser.
That was the point where the real problem became clear: having the correct routes was not enough. Blade, Vue and SSR also needed to use the same Ziggy configuration.
When the browser works but SSR does not
What confused me most was that the error did not look like a routing configuration problem. Navigation worked in the browser, the route existed in Laravel, and the client build was clean.
But SSR runs the Vue application separately inside a Node process. If that process receives a different Ziggy configuration, the result can be completely different.
One of the errors I was seeing looked like this:
TypeError: Cannot read properties of undefined (reading 'posts.index')posts.index existed. The problem was not the route itself, but which configuration Ziggy could see during SSR.
One route helper does not mean one routing context
On the frontend, it is easy to see the same route('posts.index') call everywhere and assume that all of those calls use the same routing logic.
In reality, the route helper can work with different configurations depending on where it was created and how Ziggy was installed. Blade has one context, the browser can have another, and SSR introduces one more.
The model that makes more sense to me now is:
----------------------- Laravel routes ↓ Ziggy configuration ↓┌──────────┬───────────┐ Blade Vue Inertia SSR ↓ same route APIHaving the same route() call doesn't automatically mean having the same routing contract.
ZiggyVue as the shared Vue entry point
If the application already installs ZiggyVue, I do not want every component to create its own route context.
It is simpler to provide the configuration once at application level and let components use the route helper already provided by the Vue application.
In the SSR setup, that can look like this:
.use(ZiggyVue, initialProps.ziggy)Inside a component, the route helper can then come from Vue dependency injection.
import { inject } from 'vue'; const route = inject('route');Why direct Ziggy imports became a problem
At first, a direct import feels completely natural: import route from Ziggy and call it wherever it is needed.
In an SSR application, however, that creates a risk that the component is no longer using the configuration supplied to ZiggyVue. This can remain hidden in the browser, especially once hydration has completed or the expected routes are already available there.
I ended up with a much simpler rule: Vue components use the route helper from the Vue application context.
// Instead of creating another routing contextimport { route } from 'ziggy-js';// Use the route helper provided by ZiggyVueimport { inject } from 'vue'; const route = inject('route');Inertia props connect Laravel to SSR
SSR needs a clear path for the current Ziggy configuration to travel from Laravel into the Node process.
In my case, Inertia shared props became that path. Laravel creates the configuration on the backend, it becomes part of the initial page props, and the SSR bootstrap passes it into ZiggyVue.
That means I do not need to reproduce the routing logic again in JavaScript.
'ziggy' => fn (): array => app(Ziggy::class)->toArray(),const initialProps = props.initialPage.props || {}; return createSSRApp({ render: () => h(App, props),}) .use(plugin) .use(ZiggyVue, initialProps.ziggy);Context | Ziggy configuration |
|---|---|
Laravel / Blade | Laravel routing context |
Vue in browser | Inertia page props |
Inertia SSR | The same initial page props |
Programmatic Ziggy matters too
There is another easy-to-miss consumer: Ziggy is not used only through a Blade directive or the Vue plugin.
The configuration can also be requested directly from Laravel code. If the application has a custom route projection, programmatic usage should see the same result.
That is why I also checked what the container actually returned.
$ziggy = app(\Tighten\Ziggy\Ziggy::class); $data = $ziggy->toArray();In my case, posts.index was present in that configuration. That was an important clue: route registration itself was not the problem.
How I narrowed the problem down
The most useful thing here was resisting the urge to change all the routing code at once.
First, I confirmed that Laravel knew the logical route. Then I checked the Ziggy configuration returned by the container. Only after that did I move on to what Vue and SSR were actually receiving.
This made it much easier to separate a route-registration problem from a configuration-delivery problem.
confirm that the route exists in Laravel;
inspect the backend Ziggy configuration;
inspect
initialProps.ziggy;check where the component gets
routefrom;test the browser separately from SSR;
rebuild both client and SSR bundles after changes.
What finally worked
The final solution was not one magic line.
The routing flow had to become consistent:
The backend creates the correct Ziggy configuration;
Inertia carries it in page props;
SSR uses those props during bootstrap;
Vue components take
routefrom the ZiggyVue context.
Once that happened, the browser and SSR stopped living in two different routing worlds.
--------------------------- Laravel ↓ Logical Ziggy configuration ↓ Inertia props ↓ ZiggyVue ↓ Vue components ↓ Browser + SSRI didn't want to fix every component. I wanted to fix the place where the application gets its routing context.
What I verify after routing changes
After routing or SSR changes, a successful npm run build is no longer enough for me.
I saw a similar issue with meta tags in Inertia SSR: everything looked correct in the browser, while the expected meta tags were still missing from the initial HTML.
client build succeeds;
SSR build succeeds;
the SSR process is running;
a normal public page loads;
locale switching works;
Vue navigation has no console errors;
admin navigation has no regressions;
Laravel logs contain no new
SsrExceptionafter the smoke test.
I also find it useful to place a marker in the log before starting a smoke test. Old SSR errors are surprisingly easy to confuse with new ones when processes have been restarted several times and different pages have been tested.
Now I only inspect entries created after a specific controlled request.
Log markers instead of guessing
A simple wc -l turned out to be a very useful debugging tool.
wc -l storage/logs/laravel-2026-08-12.logAfter the controlled request, I read only the new lines.
tail -n +121 storage/logs/laravel-2026-08-12.logThe simple rules I kept
one routing contract for browser and SSR;
one Ziggy configuration per request;
Vue components do not create their own Ziggy context;
localization details stay behind the backend boundary;
a successful build does not replace an SSR smoke test;
logs are checked only after a controlled marker.
Conclusion
Ziggy itself was not really the problem. The problem was that one application can quietly end up with several different routing contexts.
Once I connected Laravel, Inertia props, ZiggyVue and SSR into one flow, the debugging became much easier. I now think of route() not simply as a helper, but as a consumer of a specific routing configuration.
There was still one interesting edge case left, though: normal pages were already clean while a 404 page could still break SSR.



