TypeScript props on Laravel Vue pages

defineProps on the Inertia page is the type of the controller payload. A missing key fails the check on your machine, before a blank v-for in the browser.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/typescript-laravel-vue-pages.md
defineProps on the Inertia page is the type of the controller payload. A missing key fails the check on your machine, before a blank v-for in the browser.
Do not add a second checker.
Do not silence it with as any.
Show the diff. Do not commit.

Next: Laravel Vite and Vue

On this page · 9 sections
  1. The page
  2. The posts.data trap
  3. Paginator shapes differ
  4. What stays local
  5. Run the check
  6. Where this stops
  7. Recap
  8. FAQ
  9. Next steps
  10. Sources

Your Inertia page renders an empty list and the console is silent. The controller sent posts.data, the template looped over posts. One defineProps type turns that silent bug into a red line before you open the browser.

TL;DR

  • Type the props in the page with defineProps<{ posts: Page }>(), matching what the controller actually returns.
  • A paginator wraps rows in data; loop over posts.data, not posts.
  • Dates are JSON strings, so type them as string | null.
  • Keep the interface next to the page; run the kit’s vue-tsc check.

What you'll learn

  • How to type an Inertia page with defineProps
  • The shape a Laravel paginator sends
  • Why dates arrive as strings
  • Where to keep the types

Who it's for: Laravel + Vue developers on the TypeScript starter kit

Applies to: Laravel 13 · Vue starter kit · Vue 3.5 · TypeScript · vue-tsc

The Vue starter kit is already TypeScript. The page is untyped until you say what the controller sends. defineProps on that single file is enough. You do not need a shared types package, and you do not need Vue Router. Routing stays in Laravel, which is the setup in Inertia, a SPA, or Nuxt.

The list this types is the CRUD guide. The first setup page returned posts as an array. The CRUD page wraps that array in data. Type the shape you actually return.

The page#

resources/js/pages/posts/Index.vuevue
<script setup lang="ts">
import { Head, Link } from "@inertiajs/vue3";

interface Post {
  id: number;
  title: string;
  excerpt: string;
  published_at: string | null;
}

interface Page {
  data: Post[];
  current_page: number;
  last_page: number;
  prev_page_url: string | null;
  next_page_url: string | null;
}

defineProps<{
  posts: Page;
  filters: { search: string };
}>();
</script>

<template>
  <Head title="Posts" />
  <article v-for="post in posts.data" :key="post.id">
    <h2>{{ post.title }}</h2>
    <Link :href="`/posts/${post.id}/edit`">Edit</Link>
  </article>
</template>

Gotcha: dates are strings

published_at is a string in the props because Inertia serializes dates to JSON. Typing it as Date will not make it a Date. Format it as a string, or parse it on purpose.

The posts.data trap#

posts.data is the row list. v-for="post in posts" walks the paginator’s values, the data array and then current_page and the rest, so post.title comes out blank. The key is the second alias, (value, key). The type above makes that mistake a type error if you annotate the v-for source as Post[] and then pass posts instead of posts.data.

Blank titles, then Rows:

resources/js/pages/posts/Index.vuevue
<article v-for="post in posts" :key="post.id">
resources/js/pages/posts/Index.vuevue
<article v-for="post in posts.data" :key="post.id">

Paginator shapes differ#

Controller methodHas last_page and total?
paginate()Yes
simplePaginate()No
cursorPaginate()No (cursor URLs instead)

A simple paginator or a cursor paginator has no last_page. Drop that field from Page when the controller uses those methods. The differences are pagination.

What stays local#

Keep this interface next to the page that receives it. A global PageProps augmentation is worth it later, when every page reads the same shared auth user. It is the wrong first step. One wrong global type then lies to every page.

Wayfinder types the URL you visit. It does not type these props. That generator is Wayfinder.

Run the check#

Run the kit’s TypeScript check. A fresh starter kit calls vue-tsc. The script name is whatever package.json already has. Do not add a second checker.

Break it on purpose

Change posts.data to posts in the template, then run the check:

bash
npx vue-tsc --noEmit

Put it back and the error goes away.

Heads up: no as any

A red posts.data means the controller and the page disagree. Fix the one that is wrong. Do not silence it with as any.

Where this stops#

Component tests for a presentational child are tests. They mount the child with a title string. They do not replace defineProps on the Inertia page.

Recap#

  • defineProps<...>() on the page types the controller payload.
  • Paginators wrap rows in data; simple and cursor paginators have no last_page.
  • JSON dates are strings.
  • Keep types local first; let vue-tsc catch disagreements.

FAQ#

Why is my Inertia v-for rendering blank rows?

You are looping over the paginator object instead of its rows. Use posts.data.

Should I type Laravel dates as Date in Vue?

No. Inertia sends JSON, so dates arrive as strings. Type them as string | null and parse on purpose.

Do I need a global PageProps type?

Not at first. Keep interfaces next to the page. A global augmentation pays off later, for shared props like the auth user.

Does Wayfinder type my page props?

No. Wayfinder types routes and URLs. Props are typed with defineProps on the page.

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.