November 19th, 2025

TypeScript + Inertia 2.0: The Laravel Developer's Practical Guide

TypeScript + Inertia 2.0: The Laravel Developer's Practical Guide

Look, I'll be honest with you TypeScript used to scare me. Actually, it still does sometimes.

I'm a Laravel developer. I love PHP 8.3 type system. I use typed properties, return types, and union types every day. But for the longest time, I avoided TypeScript in my frontend code like it was some sort of complicated monster.

The thing is, I was making it harder than it needed to be. Once I realised that TypeScript for React is basically the same skill as typing PHP code (just different syntax), everything clicked into place.

If you're building with Inertia 2.0 and React, TypeScript isn't just nice to have it makes the new async features actually usable in production. Let me show you why.

Why TypeScript Matters with Inertia 2.0

Inertia 2.0 brought us some brilliant new features:

  • Async requests that don't block each other
  • Prefetching for instant page loads
  • Deferred props for loading heavy data in the background
  • Polling for real-time updates

But here's the problem: these features all involve data that loads at different times. Without types, you're constantly guessing:

  • Is this prop loaded yet?
  • What shape does this data have?
  • Did the API change and I missed it?

With TypeScript, your IDE just tells you. No guessing, no bugs from typos, no runtime errors from wrong data shapes.

The Basics (if you know PHP types, you already know this)

Let's start simple. In PHP, you'd write:

1class User
2{
3 public function __construct(
4 public int $id,
5 public string $name,
6 public string $email,
7 public ?string $avatar,
8 ) {}
9}
10 
11public function show(User $user): Response
12{
13 return Inertia::render('Users/Show', [
14 'user' => $user
15 ]);
16}

The TypeScript equivalent is almost identical:

1interface User {
2 id: number
3 name: string
4 email: string
5 avatar: string | null
6}
7 
8interface Props {
9 user: User
10}
11 
12export default function Show({ user }: Props) {
13 // TypeScript knows exactly what `user` contains
14 return (
15 <div>
16 <h1>{user.name}</h1>
17 <img src={user.avatar ?? '/default.jpg'} />
18 </div>
19 )
20}

See? Same idea. Just different punctuation.

Setting up TypeScript with the new Laravel starter kit

Good news: if you're using Laravel's new React starter kit (the one with shadcn/ui), TypeScript is already set up for you. Just run:

1laravel new my-app
2# Select "React" when prompted

You get:

  • TypeScript configured
  • React 19
  • Inertia 2.0
  • All the types you need

The starter kit even includes a tsconfig.json that just works:

1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "useDefineForClassFields": true,
5 "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 "module": "ESNext",
7 "skipLibCheck": true,
8 "moduleResolution": "bundler",
9 "allowImportingTsExtensions": true,
10 "resolveJsonModule": true,
11 "isolatedModules": true,
12 "noEmit": true,
13 "jsx": "react-jsx",
14 "strict": true,
15 "noUnusedLocals": true,
16 "noUnusedParameters": true,
17 "noFallthroughCasesInSwitch": true,
18 "paths": {
19 "@/*": ["./resources/js/*"]
20 }
21 },
22 "include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"],
23 "references": [{ "path": "./tsconfig.node.json" }]
24}

Don't worry about understanding all of this. The important bits:

  • "strict": true - catches bugs early
  • "paths" - lets you use @/components instead of ../../../components
  • "jsx": "react-jsx" - makes React work with TypeScript

Real Inertia 2.0 Examples

Let's look at how TypeScript makes Inertia 2.0's new features actually useful.

Prefetching with known data shapes

Inertia 2.0 can prefetch pages before users click on them. By default, it prefetches when you hover over a link for more than 75ms.

1import { Link } from '@inertiajs/react'
2 
3interface User {
4 id: number
5 name: string
6 email: string
7}
8 
9export default function UsersList({ users }: { users: User[] }) {
10 return (
11 <div>
12 {users.map(user => (
13 <Link
14 key={user.id}
15 href={`/users/${user.id}`}
16 prefetch
17 >
18 {user.name}
19 </Link>
20 ))}
21 </div>
22 )
23}

Without types, you'd have to remember what data /users/{id} returns. With types, you just write your component and TypeScript tells you if you get it wrong.

Deferred Props (this is where TypeScript shines)

Deferred props let you load slow data in the background. But they might not be available immediately, so you need to handle that.

1interface DashboardProps {
2 stats: {
3 users: number
4 revenue: number
5 }
6 // Analytics loads after the page renders
7 analytics?: {
8 views: number[]
9 conversions: number[]
10 }
11}
12 
13export default function Dashboard({ stats, analytics }: DashboardProps) {
14 return (
15 <div>
16 {/* Stats always available */}
17 <QuickStats data={stats} />
18 
19 {/* Analytics might not be loaded yet */}
20 {analytics ? (
21 <AnalyticsChart data={analytics} />
22 ) : (
23 <div>Loading analytics...</div>
24 )}
25 </div>
26 )
27}

See the ? in analytics?:? That tells TypeScript this prop is optional. Now if you forget to check if it exists, TypeScript will complain:

1// TypeScript error: Object is possibly 'undefined'
2<AnalyticsChart data={analytics} />
3 
4// This works fine
5{analytics && <AnalyticsChart data={analytics} />}

On the Laravel side:

1return Inertia::render('Dashboard', [
2 'stats' => $quickStats,
3 'analytics' => Inertia::defer(fn() => $expensiveAnalyticsQuery)
4]);

The useForm Hook (Type-Safe Forms)

Forms in Inertia are brilliant, but without types they're a pain. With TypeScript, you get autocomplete for everything:

1import { useForm } from '@inertiajs/react'
2 
3interface UserFormData {
4 name: string
5 email: string
6 avatar: File | null
7}
8 
9export default function CreateUser() {
10 const { data, setData, post, processing, errors } = useForm<UserFormData>({
11 name: '',
12 email: '',
13 avatar: null
14 })
15 
16 function submit(e: React.FormEvent) {
17 e.preventDefault()
18 post('/users')
19 }
20 
21 return (
22 <form onSubmit={submit}>
23 <input
24 type="text"
25 value={data.name}
26 onChange={e => setData('name', e.target.value)}
27 />
28 {errors.name && <div>{errors.name}</div>}
29 
30 <input
31 type="email"
32 value={data.email}
33 onChange={e => setData('email', e.target.value)}
34 />
35 {errors.email && <div>{errors.email}</div>}
36 
37 <input
38 type="file"
39 onChange={e => setData('avatar', e.target.files?.[0] ?? null)}
40 />
41 
42 <button type="submit" disabled={processing}>
43 {processing ? 'Creating...' : 'Create User'}
44 </button>
45 </form>
46 )
47}

TypeScript knows:

  • data.name is a string
  • setData only accepts valid field names
  • errors can have name, email, or avatar properties
  • processing is a boolean

Try to use setData('wrong_field', 'value') and TypeScript will stop you before you even run the code.

Async router calls

Inertia 2.0's router methods are now async by default. TypeScript helps you handle this properly:

1import { router } from '@inertiajs/react'
2 
3async function refreshComments() {
4 // Non-blocking reload
5 router.reload({
6 only: ['comments'],
7 preserveScroll: true
8 })
9}
10 
11function prefetchNextPage(page: number) {
12 router.prefetch(`/posts?page=${page}`)
13}

Generating types from Laravel

Typing your props manually is fine for small apps. But for real projects, you want types generated automatically from your Laravel code.

Option 1: Laravel Data + TypeScript Transformer

Install the packages:

1composer require spatie/laravel-data
2composer require spatie/laravel-typescript-transformer --dev
3npm install -D @types/node

Create a Data object in Laravel:

1use Spatie\LaravelData\Data;
2 
3class UserData extends Data
4{
5 public function __construct(
6 public int $id,
7 public string $name,
8 public string $email,
9 public ?string $avatar,
10 public CarbonImmutable $created_at,
11 ) {}
12}

Configure the transformer in config/typescript-transformer.php:

1'collectors' => [
2 Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class,
3],
4 
5'output_file' => resource_path('js/types/generated.d.ts'),

Generate types:

1php artisan typescript:transform

This creates resources/js/types/generated.d.ts:

1export interface UserData {
2 id: number
3 name: string
4 email: string
5 avatar: string | null
6 created_at: string
7}

Now use it in your components:

1import { UserData } from '@/types/generated'
2 
3interface Props {
4 user: UserData
5}
6 
7export default function UserProfile({ user }: Props) {
8 // Full type safety from PHP to React
9 return <div>{user.name}</div>
10}

Option 2: Ziggy for Type-Safe Routes

Ziggy gives you Laravel routes in JavaScript. Install it:

1composer require tightenco/ziggy
2npm install ziggy-js

Generate routes:

1php artisan ziggy:generate

Use in TypeScript:

1import { route } from 'ziggy-js'
2 
3// TypeScript knows all your routes
4<Link href={route('users.show', user.id)}>
5 View User
6</Link>
7 
8// Autocomplete for route names
9router.visit(route('dashboard'))

Generics (the one thing PHP doesn't have)

Generics are the only part of TypeScript that doesn't exist in PHP. But they're dead useful for pagination.

1interface Paginator<T> {
2 data: T[]
3 current_page: number
4 last_page: number
5 per_page: number
6 total: number
7}
8 
9interface Props {
10 users: Paginator<UserData>
11 posts: Paginator<PostData>
12}

Now users.data is typed as UserData[] and posts.data is PostData[]. The Paginator type works for any data type you pass in.

Common Patterns

Optional props with defaults

1interface Props {
2 user: UserData
3 showAvatar?: boolean // Optional, defaults to undefined
4}
5 
6export default function UserCard({ user, showAvatar = true }: Props) {
7 return (
8 <div>
9 <h2>{user.name}</h2>
10 {showAvatar && user.avatar && (
11 <img src={user.avatar} alt={user.name} />
12 )}
13 </div>
14 )
15}

Union types for different states

1type LoadingState =
2 | { status: 'idle' }
3 | { status: 'loading' }
4 | { status: 'success', data: UserData[] }
5 | { status: 'error', message: string }
6 
7function UsersList({ state }: { state: LoadingState }) {
8 switch (state.status) {
9 case 'loading':
10 return <div>Loading...</div>
11 case 'success':
12 return <div>{state.data.length} users</div> // TypeScript knows data exists
13 case 'error':
14 return <div>Error: {state.message}</div> // TypeScript knows message exists
15 default:
16 return null
17 }
18}

Typing children and events

1interface ButtonProps {
2 onClick: (e: React.MouseEvent) => void
3 children: React.ReactNode
4 disabled?: boolean
5}
6 
7export function Button({ onClick, children, disabled = false }: ButtonProps) {
8 return (
9 <button onClick={onClick} disabled={disabled}>
10 {children}
11 </button>
12 )
13}

Gradual Adoption

You don't need to type everything at once. TypeScript works fine alongside JavaScript. Here's how to start:

  1. Rename one file from .jsx to .tsx
  2. Add prop types to that component
  3. Let TypeScript infer the rest (don't add types everywhere)
  4. Repeat with more components as you touch them

Example of what NOT to do:

1// Too much typing (TypeScript can infer this)
2const [count, setCount] = useState<number>(0)
3const [name, setName] = useState<string>('')
4 
5// Better (TypeScript already knows)
6const [count, setCount] = useState(0) // Inferred as number
7const [name, setName] = useState('') // Inferred as string

Only type things when TypeScript can't figure it out:

1// TypeScript can't infer this, so we help it
2const [user, setUser] = useState<UserData | null>(null)
3 
4// This is needed because the array starts empty
5const [users, setUsers] = useState<UserData[]>([])

Handling Errors

TypeScript will complain sometimes. Here's how to fix the common ones:

"Property 'X' does not exist"

This usually means you're accessing a property that might not exist:

1// Error: Property 'analytics' does not exist
2const total = props.analytics.views.length
3 
4// Fix: Check it exists first
5const total = props.analytics?.views.length ?? 0

"Argument of type 'X' is not assignable to parameter of type 'Y'"

You're passing the wrong type:

1// Error: Expected number, got string
2setData('age', '25')
3 
4// Fix: Convert to number
5setData('age', parseInt('25'))

"Object is possibly 'null'"

TypeScript thinks something might be null:

1// Error
2const file = e.target.files[0]
3 
4// Fix: Check it exists
5const file = e.target.files?.[0] ?? null

Real Production Example

Here's a complete example showing how TypeScript helps with Inertia 2.0's features:

1import { useForm } from '@inertiajs/react'
2import { UserData } from '@/types/generated'
3 
4interface DashboardProps {
5 user: UserData
6 stats: {
7 revenue: number
8 customers: number
9 }
10 // Deferred prop - loads in background
11 recentActivity?: Array<{
12 id: number
13 type: 'sale' | 'signup' | 'cancellation'
14 created_at: string
15 }>
16}
17 
18export default function Dashboard({ user, stats, recentActivity }: DashboardProps) {
19 const form = useForm({
20 search: ''
21 })
22 
23 function handleSearch(e: React.FormEvent) {
24 e.preventDefault()
25 form.get('/search', {
26 preserveState: true,
27 preserveScroll: true,
28 only: ['results']
29 })
30 }
31 
32 return (
33 <div>
34 <h1>Welcome back, {user.name}</h1>
35 
36 <div className="grid grid-cols-2 gap-4">
37 <StatCard
38 label="Revenue"
39 value={`£${stats.revenue.toLocaleString()}`}
40 />
41 <StatCard
42 label="Customers"
43 value={stats.customers}
44 />
45 </div>
46 
47 {recentActivity ? (
48 <ActivityFeed activities={recentActivity} />
49 ) : (
50 <div>Loading recent activity...</div>
51 )}
52 
53 <form onSubmit={handleSearch}>
54 <input
55 type="text"
56 value={form.data.search}
57 onChange={e => form.setData('search', e.target.value)}
58 placeholder="Search..."
59 />
60 <button type="submit" disabled={form.processing}>
61 Search
62 </button>
63 </form>
64 </div>
65 )
66}
67 
68interface StatCardProps {
69 label: string
70 value: string | number
71}
72 
73function StatCard({ label, value }: StatCardProps) {
74 return (
75 <div className="bg-white p-4 rounded">
76 <div className="text-sm text-grey-600">{label}</div>
77 <div className="text-2xl font-bold">{value}</div>
78 </div>
79 )
80}

Tools That Make It Better

VS Code Extensions

Install these:

  • TypeScript Vue Plugin (even for React, helps with types)
  • Error Lens - shows TypeScript errors inline
  • Pretty TypeScript Errors - makes errors actually readable

ESLint + TypeScript

Add to your eslint.config.js:

1import typescript from '@typescript-eslint/eslint-plugin'
2import tsParser from '@typescript-eslint/parser'
3 
4export default [
5 {
6 files: ['**/*.ts', '**/*.tsx'],
7 languageOptions: {
8 parser: tsParser,
9 parserOptions: {
10 project: './tsconfig.json'
11 }
12 },
13 plugins: {
14 '@typescript-eslint': typescript
15 },
16 rules: {
17 '@typescript-eslint/no-unused-vars': 'error',
18 '@typescript-eslint/no-explicit-any': 'warn'
19 }
20 }
21]

The Bottom Line

TypeScript with Inertia 2.0 isn't about writing more code. It's about writing code once and having it work.

Inertia 2.0's async features (prefetching, deferred props, polling) all involve data loading at different times. Without types, you're constantly checking "is this loaded yet?" and "what shape is this data?"

With TypeScript, your IDE tells you. Your code either compiles or it doesn't. No runtime surprises.

And the best part? If you already write typed PHP in Laravel, you already know how to write TypeScript. It's the same skill, just different brackets.

Start with one component. Add types to the props. See how much easier it makes your life. Then do another one. Before you know it, you'll wonder how you ever built Inertia apps without it.

Statamic Ninja

Comments

Marian Pop

PHP / Laravel Developer. Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.