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 overposts.data, notposts. - Dates are JSON strings, so type them as
string | null. - Keep the interface next to the page; run the kit’s
vue-tsccheck.
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#
<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:
<article v-for="post in posts" :key="post.id"><article v-for="post in posts.data" :key="post.id">Paginator shapes differ#
| Controller method | Has 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:
npx vue-tsc --noEmitPut 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 nolast_page. - JSON dates are strings.
- Keep types local first; let
vue-tsccatch 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#
- Paginate a Laravel list in Vue: which paginator sends what.
- Laravel Wayfinder with Vue: typed routes.
- Test a Laravel + Vue app: component and feature tests.
- Vue: TypeScript with the Composition API



