Laravel Daily's

TypeScript in Your Laravel + Vue + Inertia App: Typed Props from Route to Component

Illustration of typed data flowing from a Laravel controller through Inertia into a Vue component

A Laravel controller can return the right data and still leave your Vue component guessing.

That gap appears when a property is renamed, a nullable value becomes required, or a backend query changes shape. The page may compile, then fail only after a user reaches the affected route.

TypeScript closes that gap across your Laravel, Inertia, and Vue layers. You define the contract once, apply it to page props and shared data, then carry it into forms, polling, and deferred responses.

This approach works well for teams using Laravel as a productive PHP web framework with a modern Vue frontend. It also gives your PHP developer tools a clear boundary with the browser.

Diagram showing Laravel, Inertia, TypeScript, and Vue connected by typed data contracts

Start with the response contract

Consider a controller that renders a project index page:

use Inertia\Inertia;
use Inertia\Response;

public function index(): Response
{
    return Inertia::render('Projects/Index', [
        'projects' => Project::query()
            ->with('owner:id,name')
            ->latest()
            ->get()
            ->map(fn (Project $project) => [
                'id' => $project->id,
                'name' => $project->name,
                'status' => $project->status,
                'owner' => [
                    'name' => $project->owner->name,
                ],
            ]),
    ]);
}

The controller returns a predictable structure. Each project has an ID, name, status, and owner name.

Now represent that structure in TypeScript:

// resources/js/types/projects.ts

export interface ProjectSummary {
  id: number
  name: string
  status: 'active' | 'archived'
  owner: {
    name: string
  }
}

export interface PageProps {
  projects: ProjectSummary[]
}

This interface is the contract between ProjectsController and Projects/Index.vue.

It does not inspect PHP automatically. TypeScript cannot see the result of an Eloquent query. The interface documents the response and lets your frontend compiler check how the data is used.

Type the Vue page at its boundary

Use <script setup lang="ts"> and pass the interface to defineProps:

<script setup lang="ts">
import type { PageProps } from '@/types/projects'

const props = defineProps<PageProps>()
</script>

<template>
  <section>
    <h1>Projects</h1>

    <article v-for="project in props.projects" :key="project.id">
      <h2>{{ project.name }}</h2>
      <p>{{ project.owner.name }}</p>
      <span>{{ project.status }}</span>
    </article>
  </section>
</template>

TypeScript now knows that projects is an array. It also knows that status accepts only active or archived.

A typo such as project.owner.fullName fails during development. So does an invalid status comparison:

if (project.status === 'pending') {
  // TypeScript reports an error.
}

That feedback matters as the application grows. A page component should not need to rediscover the shape of every Laravel response.

Type page props with usePage

defineProps is ideal when the page component reads its own props directly. usePage is useful when nested components or composables need access to the current Inertia page.

<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
import type { PageProps } from '@/types/projects'

const page = usePage<PageProps>()

const projects = page.props.projects
</script>

Inertia merges your page-specific generic with globally configured shared props. That means page.props.projects and shared values such as page.props.auth can both receive autocomplete and type checking.

The official Inertia TypeScript documentation supports this pattern through generics and declaration merging.

Define shared data once

Most applications share a small set of values with every page. Common examples include the authenticated user, application name, permissions, and flash messages.

Laravel commonly provides this data through HandleInertiaRequests:

public function share(Request $request): array
{
    return [
        'appName' => config('app.name'),

        'auth' => [
            'user' => $request->user()?->only([
                'id',
                'name',
                'email',
            ]),
        ],

        'flash' => [
            'success' => fn () => $request->session()->get('success'),
        ],
    ];
}

Create a declaration file for the shared contract:

// resources/js/types/inertia.d.ts

import '@inertiajs/core'

export interface SharedPageProps {
  appName: string
  auth: {
    user: {
      id: number
      name: string
      email: string
    } | null
  }
  flash: {
    success?: string
  }
}

declare module '@inertiajs/core' {
  interface InertiaConfig {
    sharedPageProps: SharedPageProps
  }
}

The import is important. It makes this file a module augmentation instead of replacing the original Inertia module.

Your tsconfig.json must also include the declaration file:

{
  "include": [
    "resources/js/**/*.ts",
    "resources/js/**/*.vue",
    "resources/js/**/*.d.ts"
  ]
}

You can now use shared data without repeating its shape in every page:

<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'

const page = usePage()

const userName = page.props.auth.user?.name ?? 'Guest'
</script>

<template>
  <p>Welcome, {{ userName }}</p>
</template>

This follows the same principle as Laravel’s shared data documentation: define global page data centrally, then consume it consistently.

Keep deferred props explicit

Deferred props are useful when a page can render before an expensive query finishes. For example, a project page might load its main list immediately and defer statistics.

public function index(): Response
{
    return Inertia::render('Projects/Index', [
        'projects' => $this->projects(),

        'stats' => Inertia::defer(fn () => [
            'active' => Project::where('status', 'active')->count(),
            'archived' => Project::where('status', 'archived')->count(),
        ]),
    ]);
}

Model the initial loading state in TypeScript:

export interface ProjectStats {
  active: number
  archived: number
}

export type Deferred<T> = T | undefined

export interface PageProps {
  projects: ProjectSummary[]
  stats: Deferred<ProjectStats>
}

Then guard the value in the component:

<script setup lang="ts">
import type { PageProps } from '@/types/projects'

const props = defineProps<PageProps>()
</script>

<template>
  <section>
    <h2>Project statistics</h2>

    <div v-if="props.stats">
      <span>Active: {{ props.stats.active }}</span>
      <span>Archived: {{ props.stats.archived }}</span>
    </div>

    <p v-else>Loading statistics…</p>
  </section>
</template>

The important detail is not the alias itself. It is the explicit loading state. TypeScript forces the component to decide what happens before the deferred value arrives.

If your backend returns null instead of omitting the value, use T | null. Match the type to your actual Laravel response contract.

Illustration of deferred Laravel data becoming a typed TypeScript object for a Vue component

Type forms with useForm

Inertia’s form helper accepts a generic. Use it for the data sent back to Laravel:

import { useForm } from '@inertiajs/vue3'

interface InviteForm {
  email: string
  role: 'member' | 'admin'
}

const form = useForm<InviteForm>({
  email: '',
  role: 'member',
})

function inviteUser() {
  form.post('/team/invitations')
}

The form fields now have autocomplete. Typos in field names become compiler errors, and form.errors.email is linked to the same field contract.

This is particularly useful when a form grows beyond a few inputs. Nested objects and arrays can be represented directly:

interface ProjectForm {
  name: string
  tags: string[]
  settings: {
    visibility: 'private' | 'team'
  }
}

const form = useForm<ProjectForm>({
  name: '',
  tags: [],
  settings: {
    visibility: 'team',
  },
})

The Inertia forms documentation covers the runtime behavior. The generic adds a compile-time layer on top.

Use usePoll with typed surrounding data

usePoll reloads page props at an interval. In current Inertia Vue APIs, it does not take a page-data generic directly. Your page props remain typed through defineProps or usePage.

<script setup lang="ts">
import { usePoll } from '@inertiajs/vue3'
import type { PageProps } from '@/types/projects'

const props = defineProps<PageProps>()

const { polling, start, stop } = usePoll(
  5000,
  { only: ['stats'] },
  { autoStart: true },
)
</script>

<template>
  <section>
    <p v-if="polling">Refreshing statistics…</p>

    <strong v-if="props.stats">
      {{ props.stats.active }} active projects
    </strong>

    <button type="button" @click="stop">
      Stop updates
    </button>

    <button type="button" @click="start">
      Start updates
    </button>
  </section>
</template>

props.stats stays typed as ProjectStats | undefined. The poll configuration limits reloads to the stats prop, while the component still handles its deferred or initial state safely.

The returned polling value is a typed Vue ref. In templates, Vue unwraps it automatically.

See the Inertia polling documentation for interval and lifecycle options.

Treat the boundary as an engineering decision

TypeScript does not validate a PHP response at runtime. If the controller returns owner_name but the interface expects owner.name, the browser still receives the wrong shape.

That is why response shaping matters. Return deliberate arrays, resources, or DTOs from Laravel instead of exposing unpredictable model structures. Keep dates, nullable fields, enum values, and nested relationships explicit.

The same discipline helps if you later build a REST API with PHP. A stable response contract can serve an Inertia page, an API consumer, or a mobile client.

For larger applications, teams often generate TypeScript definitions from PHP data objects or request classes. That reduces manual duplication, but the design principle remains the same: make the server response intentional, then carry that shape into the frontend.

Illustration of TypeScript catching a mismatch between a Laravel response and a Vue component

A practical workflow for growing apps

A reliable Laravel and Vue codebase can follow this sequence:

  1. Shape the response in the Laravel controller or resource.
  2. Create a named TypeScript interface for page-specific data.
  3. Define shared props through InertiaConfig.
  4. Model deferred values with undefined or null.
  5. Pass generics to usePage and useForm.
  6. Type the props surrounding usePoll.
  7. Run vue-tsc and your PHP test suite in CI.

This keeps the route, controller, Inertia response, and Vue component aligned as the application changes.

The payoff is not more interfaces for their own sake. It is a shorter path between a backend change and the developer who needs to update the frontend. In a full-stack PHP application, that feedback makes maintenance calmer and feature work more deliberate.

Type the boundary once. Then let Laravel, Inertia, Vue, and TypeScript keep the contract visible.

Previous
Ground Your Laravel Agent in the Real World: Web Search and Tool Search with the AI SDK
Next
View Transitions in Inertia 3.x: Silky Page Animations for Your Laravel + Vue SPA