Laravel Daily's

Laravel Wayfinder + Inertia 3.x: Typed Routes for Your Vue SPA (No More Hardcoded URLs)

Illustrated bridge connecting Laravel controllers to a Vue and Inertia single-page application with typed route cards

Hardcoded URLs are easy to write and easy to forget.

A path such as '/posts/' + post.id works until the route changes. Then the backend moves to /articles, the frontend keeps calling /posts, and the failure appears at runtime.

Laravel Wayfinder closes that gap. It generates typed TypeScript functions from your Laravel controllers and named routes. Inertia 3.x can consume those functions directly in Vue components.

You get route parameters, HTTP methods, and URL generation from the same PHP source that handles the request.

That means fewer strings to maintain and better feedback when your application changes.

Why hardcoded URLs rot

A URL string carries no information about the route that created it.

router.visit(`/posts/${post.id}`)

This line does not tell TypeScript whether:

  • The route still exists.
  • The parameter should be an ID or a slug.
  • The request should use GET, PATCH, or another method.
  • The backend renamed {post} to {article}.
  • The route gained a required query parameter.

The browser discovers those problems after the request leaves the application.

Wayfinder moves those checks into your development workflow:

import { show } from '@/actions/App/Http/Controllers/PostController'

router.visit(show(post))

The generated show function knows the route signature. It resolves the URL and includes the default HTTP method.

Wayfinder is currently in beta, so review the project changelog when upgrading. The core idea remains simple: Laravel defines the route, and TypeScript consumes a generated representation of it.

Generate typed routes from Laravel

Install Wayfinder with Composer and add its Vite plugin:

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

Register the plugin in vite.config.js:

import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import { wayfinder } from '@laravel/vite-plugin-wayfinder'

export default defineConfig({
  plugins: [
    laravel({
      input: 'resources/js/app.ts',
      refresh: true,
    }),
    wayfinder(),
  ],
})

You can also generate definitions manually:

php artisan wayfinder:generate

By default, Wayfinder writes generated definitions below resources/js. The output includes:

  • actions for controller methods.
  • routes for named routes.
  • wayfinder for generated route metadata.

The Wayfinder README documents the available generation options.

Bright vector illustration of Laravel PHP controllers generating typed TypeScript route functions for Vue

Consider a Laravel controller with model binding:

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/posts/{post:slug}', [PostController::class, 'show'])
    ->name('posts.show');

Wayfinder can generate a helper that understands the slug binding:

import { show } from '@/actions/App/Http/Controllers/PostController'

show('building-with-laravel')
show({ slug: 'building-with-laravel' })

It can also resolve model-like values:

show({ slug: post.slug })

The exact generated import path depends on your configuration. Controller actions and named routes are both available, so teams can choose the style that best matches their application.

Use typed routes with Inertia Link

Inertia 3.x accepts Wayfinder route objects directly.

<script setup lang="ts">
import { Link } from '@inertiajs/vue3'
import { show } from '@/actions/App/Http/Controllers/PostController'
</script>

<template>
  <Link :href="show(post)">
    {{ post.title }}
  </Link>
</template>

The helper returns a route definition containing the resolved URL and method. Inertia reads that definition from the href prop.

This is different from passing a plain URL string:

<Link :href="`/posts/${post.id}`">
  Read post
</Link>

The second version can silently drift from Laravel. The first version is tied to the generated controller action.

The Inertia routing documentation covers Wayfinder alongside other approaches such as Ziggy. The Links documentation shows how Link infers the URL and method from a generated object.

Use Wayfinder with router.visit

Wayfinder works with every Inertia router method.

import { router } from '@inertiajs/vue3'
import { show, index, destroy } from '@/actions/App/Http/Controllers/PostController'

router.visit(index())

router.visit(show(post))

router.delete(destroy(post))

You can still pass normal Inertia options:

router.visit(index(), {
  preserveState: true,
  preserveScroll: true,
  onSuccess: () => {
    console.log('Posts loaded')
  },
})

You can also use verb-specific methods:

router.post(store(), {
  title: 'Typed routing with Laravel',
  body: '...',
})

The generated route supplies the default method. If you explicitly pass a method option, Inertia uses the explicit value. The manual visits documentation covers this behavior.

Illustrated Vue component using a typed route helper with Inertia Link and router navigation arrows

Typed form submits without URL strings

A form submit is another place where hardcoded endpoints tend to spread.

With Inertia’s <Form> component, pass the generated route to action:

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

<template>
  <Form :action="store()">
    <input name="title" />
    <textarea name="body"></textarea>

    <button type="submit">
      Create post
    </button>
  </Form>
</template>

The generated store() route provides both the URL and the POST method.

The same pattern works with useForm:

import { useForm } from '@inertiajs/vue3'
import { store } from '@/actions/App/Http/Controllers/PostController'

const form = useForm({
  title: '',
  body: '',
})

function submit() {
  form.submit(store())
}

This keeps the example focused on the route contract. Your existing form state and validation approach can remain unchanged.

See the Inertia forms documentation for the supported <Form> and useForm patterns.

Typed standalone JSON requests with useHttp

Not every request should navigate to another Inertia page.

Search, status widgets, autocomplete, and background data often return JSON instead. Inertia 3.x provides useHttp for these standalone requests.

Wayfinder can still provide the URL. Pass the helper’s .url() result to useHttp:

import { useHttp } from '@inertiajs/vue3'
import { status } from '@/actions/App/Http/Controllers/PostStatusController'

interface StatusResponse {
  state: 'queued' | 'processing' | 'published' | 'failed'
  progress: number
  updated_at: string
}

const http = useHttp<Record<string, never>, StatusResponse>({})

async function refreshStatus(postId: number) {
  const response = await http.get(status.url(postId))

  console.log(response.state)
}

This gives you two layers of type safety:

  • Wayfinder checks the route and its parameters.
  • useHttp types the request data and JSON response.

The Laravel endpoint can be a normal controller action:

use App\Models\Post;
use Illuminate\Http\JsonResponse;

public function show(Post $post): JsonResponse
{
    return response()->json([
        'state' => $post->publishing_state,
        'progress' => $post->publishing_progress,
        'updated_at' => $post->updated_at,
    ]);
}

For larger APIs, return an API resource instead. The important part is that the endpoint returns JSON rather than an Inertia page response.

This pairing is a natural fit when you want to build a REST API with PHP while keeping the Vue client aware of its endpoint structure.

Bright illustration of a Laravel PHP controller returning typed JSON through a Vue dashboard with a polling status loop

Where usePoll fits

usePoll and useHttp solve different problems.

Use usePoll when the current Inertia page should periodically reload server-provided data:

import { usePoll } from '@inertiajs/vue3'

usePoll(5000, {
  only: ['publishingStatus'],
})

Use useHttp when a component should request a standalone JSON endpoint:

await http.get(status.url(post.id))

The route remains typed in both cases. usePoll reloads the current Inertia page. useHttp calls a JSON endpoint without triggering page navigation.

This distinction matters for dashboards and publishing workflows. A posts index can refresh its page props with usePoll, while a small status indicator can call a dedicated controller endpoint through useHttp.

A practical posts workflow

A small Laravel application might define these routes:

Route::get('/posts', [PostController::class, 'index'])
    ->name('posts.index');

Route::get('/posts/create', [PostController::class, 'create'])
    ->name('posts.create');

Route::post('/posts', [PostController::class, 'store'])
    ->name('posts.store');

Route::get('/api/posts/{post}/status', [PostStatusController::class, 'show'])
    ->name('api.posts.status');

The Vue index page can use generated actions throughout:

<script setup lang="ts">
import { Link } from '@inertiajs/vue3'
import { create, show } from '@/actions/App/Http/Controllers/PostController'

defineProps<{
  posts: Array<{
    id: number
    title: string
  }>
}>()
</script>

<template>
  <Link :href="create()">
    New post
  </Link>

  <ul>
    <li v-for="post in posts" :key="post.id">
      <Link :href="show(post)">
        {{ post.title }}
      </Link>
    </li>
  </ul>
</template>

The create page can submit to store():

import { useForm } from '@inertiajs/vue3'
import { store } from '@/actions/App/Http/Controllers/PostController'

const form = useForm({
  title: '',
  body: '',
})

function createPost() {
  form.submit(store())
}

A status widget can call the JSON endpoint with the generated URL:

import { useHttp } from '@inertiajs/vue3'
import { status } from '@/actions/App/Http/Controllers/PostStatusController'

type StatusResponse = {
  state: 'queued' | 'processing' | 'published' | 'failed'
  progress: number
}

const statusHttp = useHttp<Record<string, never>, StatusResponse>({})

function loadStatus(postId: number) {
  return statusHttp.get(status.url(postId))
}

There is no '/posts', '/posts/create', or '/api/posts/' + id in the Vue code.

Keep route changes visible

Wayfinder makes the Laravel application the source of truth, but generation still belongs in your development and deployment process.

The Vite plugin watches routes and controllers during development. Your TypeScript build can then expose broken imports or invalid parameters after a backend change.

For example, changing this route:

Route::get('/posts/{post}', ...)

to this one:

Route::get('/articles/{article}', ...)

should force the generated helper and its consumers to change together.

Run generation in CI or as part of your frontend build. If you use cached routes during deployment, follow the Wayfinder guidance and clear stale route metadata before generating:

php artisan route:clear
npm run build

You can usually keep generated directories out of version control because they are recreated during the build.

Typed routes are a small contract with a large payoff

Wayfinder does not replace Laravel’s routing conventions. It exposes them to TypeScript.

That gives your Vue SPA one dependable path to every endpoint:

  • Laravel controllers define the route.
  • Wayfinder generates the client helper.
  • Inertia consumes the helper for navigation and submissions.
  • useHttp consumes the generated URL for JSON requests.
  • TypeScript flags mismatches before users encounter them.

For Laravel teams using modern PHP developer tools, this is the useful kind of automation. It removes repetitive URL maintenance without hiding how the application works.

Explore the Laravel Starter Kits, the Wayfinder repository, and the Inertia 3.x routing guide to add typed routes to your next Vue application.

Previous
Flash Messages in Inertia 3.x: Server-Side to Vue Toast in One Line
Next
Prompt Injection Defense in Laravel: Hardening AI Agents Before They Ship