+1 (415) 599-8902

Type-Safe Routes with Laravel Wayfinder: Killing Hard-Coded URLs in Inertia, Vue and React

Every Laravel application with a JavaScript frontend has the same quiet bug factory in it: URLs typed by hand. Someone writes axios.post('/api/invoices/' + id + '/send'), someone else renames the route six months later, and nothing fails until a customer clicks the button. Blade apps have route() to protect them. Inertia, Vue, React, and Svelte frontends historically had nothing — or Ziggy, which gives you route names as strings and still lets you typo them.

Laravel Wayfinder closes that gap. It scans your routes and controllers and generates TypeScript functions — one per controller action — that your frontend imports. Rename a controller method, run the generator, and TypeScript tells you exactly which components broke. This tutorial sets it up on a real application, wires it into Vite and CI, and covers the parts the README skips: form helpers, query strings, conditional routes, and what to do when the generated output fights you.

You need Laravel 12 or newer with Vite, a TypeScript frontend, and route caching working (php artisan route:cache must succeed).

Install

composer require laravel/wayfinder
npm install --save-dev vite-plugin-run

Generate once to see what you get:

php artisan wayfinder:generate

This writes into resources/js/ by default:

  • resources/js/actions/ — a function per controller action, grouped by controller namespace.
  • resources/js/routes/ — a function per named route, grouped by name segments.
  • resources/js/wayfinder/ — the small runtime helpers the generated code uses.

Add the first two to .gitignore or commit them; both are defensible. We commit them, because a reviewer reading a pull request can then see that a route contract changed, and because a fresh npm ci on a machine without PHP still type-checks.

Wire it into Vite

Regenerating by hand is the step everybody forgets. Run the generator whenever a route or controller file changes:

// vite.config.ts
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import run from 'vite-plugin-run';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/js/app.ts'], refresh: true }),
        run([
            {
                name: 'wayfinder',
                run: ['php', 'artisan', 'wayfinder:generate'],
                pattern: ['routes/**/*.php', 'app/**/Http/**/*.php'],
            },
        ]),
    ],
});

Now npm run dev keeps the definitions current, and npm run build regenerates before bundling.

What the generated code looks like

Given a plain resource controller:

Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])->name('invoices.show');
Route::post('/invoices/{invoice}/send', [InvoiceController::class, 'send'])->name('invoices.send');

you get an import per action:

import InvoiceController from '@/actions/App/Http/Controllers/InvoiceController';

InvoiceController.show(invoice.id);
// { url: '/invoices/42', method: 'get' }

InvoiceController.send(invoice.id).url;
// '/invoices/42/send'

Each call returns an object with url and method, which is exactly the shape Inertia's router and the <Form> component want. Three details make this pleasant in practice:

Parameters are flexible. Pass a scalar, an object with the key, or a model-shaped object. All three work:

InvoiceController.show(42);
InvoiceController.show({ invoice: 42 });
InvoiceController.show(invoice); // { id: 42, ... }

If your route uses a custom key ({invoice:uuid}), Wayfinder reads it from the route definition and expects invoice.uuid.

Methods have helpers. .url(), .get(), .post(), .head() and friends are available on each action, so you can force a verb when a route registers several.

Named routes work too. If you prefer names over controller references, import from @/routes:

import { show, send } from '@/routes/invoices';

show(42).url; // '/invoices/42'

Both styles are generated; pick one per project and be consistent. We default to the actions/ style because "jump to definition" in the editor lands on the controller import, and because it survives renaming a route name.

Use it in Inertia

The payoff is in the components. With Inertia 2 and the <Form> component:

<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import InvoiceController from '@/actions/App/Http/Controllers/InvoiceController';

defineProps<{ invoice: { id: number } }>();
</script>

<template>
    <Form v-bind="InvoiceController.send(invoice.id)" #default="{ processing, errors }">
        <button type="submit" :disabled="processing">Send invoice</button>
        <p v-if="errors.email" class="text-red-600">{{ errors.email }}</p>
    </Form>
</template>

v-bind spreads action and method together — no string, no route('invoices.send', ...) call that TypeScript cannot check. The same object works with imperative navigation:

import { router } from '@inertiajs/vue3';
router.visit(InvoiceController.show(invoice.id));

and with <Link>:

<Link :href="InvoiceController.show(invoice.id)">View</Link>

In React it is the same import with JSX:

import InvoiceController from '@/actions/App/Http/Controllers/InvoiceController';

<Link href={InvoiceController.show(invoice.id)}>View</Link>

Query strings

Every generated function takes an options argument for the query:

InvoiceController.index({ query: { status: 'overdue', page: 2 } }).url;
// '/invoices?status=overdue&page=2'

Use mergeQuery when you want to keep the current query string and change one key — the common case for filter UIs and sortable tables:

InvoiceController.index({ mergeQuery: { sort: '-due_at' } });

Passing null for a key removes it, which is how you clear a filter without string surgery.

Typed form requests

Wayfinder generates URLs, not payload types. For the request body, pair it with a types generator such as spatie/laravel-typescript-transformer or a hand-written resources/js/types/ module, and type your form state against it:

import type { InvoiceSendPayload } from '@/types/generated';
import { useForm } from '@inertiajs/vue3';

const form = useForm<InvoiceSendPayload>({ email: '', note: '' });

form.submit(InvoiceController.send(invoice.id));

Now both halves of the contract — the URL and the payload — break the build when the backend changes, which is the whole point.

Trimming the output

On a large application the generator produces a lot of files, most of which the frontend never imports. Two options in config/wayfinder.php (publish it with php artisan vendor:publish --tag=wayfinder-config):

return [
    'skip' => [
        'vendor', // ignore package-registered routes
    ],
    'namespaces' => [
        'App\\Http\\Controllers' => 'actions',
    ],
];

You can also generate a subset from the CLI:

php artisan wayfinder:generate --skip-routes   # only actions/
php artisan wayfinder:generate --skip-actions  # only routes/

Tree-shaking handles the rest: Vite only bundles the functions you actually import, so unused definitions cost build time, not bundle size.

Keep it honest in CI

Generated code drifts the moment someone commits a route change without running the generator. Make CI fail loudly:

- name: Generate Wayfinder definitions
  run: php artisan wayfinder:generate

- name: Fail if definitions are stale
  run: git diff --exit-code resources/js/actions resources/js/routes

- name: Type check
  run: npx vue-tsc --noEmit   # or tsc --noEmit for React

The git diff --exit-code step is the important one. Without it, a stale commit type-checks perfectly against yesterday's routes.

Things that will trip you up

  • Closure routes are skipped. Wayfinder generates from controller actions and named routes; a Route::get('/x', fn () => ...) without a name produces nothing. Give it a name or move it to a controller.
  • Duplicate method names across HTTP verbs. If one controller method is registered for both GET and POST, use the explicit .get() / .post() helpers rather than relying on the default.
  • Route model binding with non-default keys must be declared on the route ({invoice:uuid}) or in getRouteKeyName(). Wayfinder reads the route definition, so a binding resolved only inside the controller will generate the wrong parameter name.
  • url() vs the object. Inertia's <Form> and router.visit() want the whole object; a plain <a href> wants .url. Passing the object where a string is expected renders [object Object] — TypeScript catches it if your props are typed, so type them.
  • Signed and localised URLs are still server-side concerns. Generate those in the controller and pass them as props.
  • Migrating from Ziggy does not have to be a big bang. Both can coexist; convert one page at a time, and delete Ziggy's @routes directive only when the last route() call is gone.

Is it worth it on an existing app?

On a Blade-heavy application, no — route() already gives you the same protection. On any application with a TypeScript frontend and more than a handful of endpoints, yes, and the install is an afternoon. The measurable win is that a class of bug which previously reached production (a renamed or removed endpoint that only fails at runtime, on one rarely-used screen) now fails in CI.

We install Wayfinder as part of most Inertia and Vue work, usually alongside a payload type generator so the entire frontend/backend contract is checked by the compiler. If you have a Laravel application with a JavaScript frontend that keeps breaking in small, embarrassing ways, our Laravel feature enhancement and consulting teams do exactly this kind of hardening — get in touch.