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

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

Eric Van Johnson ·

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:

class User
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public ?string $avatar,
    ) {}
}

public function show(User $user): Response
{
    return Inertia::render('Users/Show', [
        'user' => $user
    ]);
}

The TypeScript equivalent is almost identical:

interface User {
    id: number
    name: string
    email: string
    avatar: string | null
}

interface Props {
    user: User
}

export default function Show({ user }: Props) {
    // TypeScript knows exactly what `user` contains
    return (
        <div>
            <h1>{user.name}</h1>
            <img src={user.avatar ?? '/default.jpg'} />
        </div>
    )
}

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:

laravel new my-app
# 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:

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "paths": {
      "@/*": ["./resources/js/*"]
    }
  },
  "include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

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.

import { Link } from '@inertiajs/react'

interface User {
    id: number
    name: string
    email: string
}

export default function UsersList({ users }: { users: User[] }) {
    return (
        <div>
            {users.map(user => (
                <Link 
                    key={user.id}
                    href={`/users/${user.id}`}
                    prefetch
                >
                    {user.name}
                </Link>
            ))}
        </div>
    )
}

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.

interface DashboardProps {
    stats: {
        users: number
        revenue: number
    }
    // Analytics loads after the page renders
    analytics?: {
        views: number[]
        conversions: number[]
    }
}

export default function Dashboard({ stats, analytics }: DashboardProps) {
    return (
        <div>
            {/* Stats always available */}
            <QuickStats data={stats} />
            
            {/* Analytics might not be loaded yet */}
            {analytics ? (
                <AnalyticsChart data={analytics} />
            ) : (
                <div>Loading analytics...</div>
            )}
        </div>
    )
}

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

// TypeScript error: Object is possibly 'undefined'
<AnalyticsChart data={analytics} />

// This works fine
{analytics && <AnalyticsChart data={analytics} />}

On the Laravel side:

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

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:

import { useForm } from '@inertiajs/react'

interface UserFormData {
    name: string
    email: string
    avatar: File | null
}

export default function CreateUser() {
    const { data, setData, post, processing, errors } = useForm<UserFormData>({
        name: '',
        email: '',
        avatar: null
    })

    function submit(e: React.FormEvent) {
        e.preventDefault()
        post('/users')
    }

    return (
        <form onSubmit={submit}>
            <input
                type="text"
                value={data.name}
                onChange={e => setData('name', e.target.value)}
            />
            {errors.name && <div>{errors.name}</div>}
            
            <input
                type="email"
                value={data.email}
                onChange={e => setData('email', e.target.value)}
            />
            {errors.email && <div>{errors.email}</div>}
            
            <input
                type="file"
                onChange={e => setData('avatar', e.target.files?.[0] ?? null)}
            />
            
            <button type="submit" disabled={processing}>
                {processing ? 'Creating...' : 'Create User'}
            </button>
        </form>
    )
}

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:

import { router } from '@inertiajs/react'

async function refreshComments() {
    // Non-blocking reload
    router.reload({ 
        only: ['comments'],
        preserveScroll: true 
    })
}

function prefetchNextPage(page: number) {
    router.prefetch(`/posts?page=${page}`)
}

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:

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

Create a Data object in Laravel:

use Spatie\LaravelData\Data;

class UserData extends Data
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public ?string $avatar,
        public CarbonImmutable $created_at,
    ) {}
}

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

'collectors' => [
    Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class,
],

'output_file' => resource_path('js/types/generated.d.ts'),

Generate types:

php artisan typescript:transform

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

export interface UserData {
    id: number
    name: string
    email: string
    avatar: string | null
    created_at: string
}

Now use it in your components:

import { UserData } from '@/types/generated'

interface Props {
    user: UserData
}

export default function UserProfile({ user }: Props) {
    // Full type safety from PHP to React
    return <div>{user.name}</div>
}

Option 2: Ziggy for Type-Safe Routes

Ziggy gives you Laravel routes in JavaScript. Install it:

composer require tightenco/ziggy
npm install ziggy-js

Generate routes:

php artisan ziggy:generate

Use in TypeScript:

import { route } from 'ziggy-js'

// TypeScript knows all your routes
<Link href={route('users.show', user.id)}>
    View User
</Link>

// Autocomplete for route names
router.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.

interface Paginator<T> {
    data: T[]
    current_page: number
    last_page: number
    per_page: number
    total: number
}

interface Props {
    users: Paginator<UserData>
    posts: Paginator<PostData>
}

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

interface Props {
    user: UserData
    showAvatar?: boolean  // Optional, defaults to undefined
}

export default function UserCard({ user, showAvatar = true }: Props) {
    return (
        <div>
            <h2>{user.name}</h2>
            {showAvatar && user.avatar && (
                <img src={user.avatar} alt={user.name} />
            )}
        </div>
    )
}

Union types for different states

type LoadingState = 
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success', data: UserData[] }
    | { status: 'error', message: string }

function UsersList({ state }: { state: LoadingState }) {
    switch (state.status) {
        case 'loading':
            return <div>Loading...</div>
        case 'success':
            return <div>{state.data.length} users</div>  // TypeScript knows data exists
        case 'error':
            return <div>Error: {state.message}</div>  // TypeScript knows message exists
        default:
            return null
    }
}

Typing children and events

interface ButtonProps {
    onClick: (e: React.MouseEvent) => void
    children: React.ReactNode
    disabled?: boolean
}

export function Button({ onClick, children, disabled = false }: ButtonProps) {
    return (
        <button onClick={onClick} disabled={disabled}>
            {children}
        </button>
    )
}

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:

// Too much typing (TypeScript can infer this)
const [count, setCount] = useState<number>(0)
const [name, setName] = useState<string>('')

// Better (TypeScript already knows)
const [count, setCount] = useState(0)  // Inferred as number
const [name, setName] = useState('')   // Inferred as string

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

// TypeScript can't infer this, so we help it
const [user, setUser] = useState<UserData | null>(null)

// This is needed because the array starts empty
const [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:

// Error: Property 'analytics' does not exist
const total = props.analytics.views.length

// Fix: Check it exists first
const total = props.analytics?.views.length ?? 0

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

You're passing the wrong type:

// Error: Expected number, got string
setData('age', '25')

// Fix: Convert to number
setData('age', parseInt('25'))

"Object is possibly 'null'"

TypeScript thinks something might be null:

// Error
const file = e.target.files[0]

// Fix: Check it exists
const file = e.target.files?.[0] ?? null

Real Production Example

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

import { useForm } from '@inertiajs/react'
import { UserData } from '@/types/generated'

interface DashboardProps {
    user: UserData
    stats: {
        revenue: number
        customers: number
    }
    // Deferred prop - loads in background
    recentActivity?: Array<{
        id: number
        type: 'sale' | 'signup' | 'cancellation'
        created_at: string
    }>
}

export default function Dashboard({ user, stats, recentActivity }: DashboardProps) {
    const form = useForm({
        search: ''
    })

    function handleSearch(e: React.FormEvent) {
        e.preventDefault()
        form.get('/search', {
            preserveState: true,
            preserveScroll: true,
            only: ['results']
        })
    }

    return (
        <div>
            <h1>Welcome back, {user.name}</h1>
            
            <div className="grid grid-cols-2 gap-4">
                <StatCard 
                    label="Revenue" 
                    value={`£${stats.revenue.toLocaleString()}`} 
                />
                <StatCard 
                    label="Customers" 
                    value={stats.customers} 
                />
            </div>

            {recentActivity ? (
                <ActivityFeed activities={recentActivity} />
            ) : (
                <div>Loading recent activity...</div>
            )}

            <form onSubmit={handleSearch}>
                <input
                    type="text"
                    value={form.data.search}
                    onChange={e => form.setData('search', e.target.value)}
                    placeholder="Search..."
                />
                <button type="submit" disabled={form.processing}>
                    Search
                </button>
            </form>
        </div>
    )
}

interface StatCardProps {
    label: string
    value: string | number
}

function StatCard({ label, value }: StatCardProps) {
    return (
        <div className="bg-white p-4 rounded">
            <div className="text-sm text-grey-600">{label}</div>
            <div className="text-2xl font-bold">{value}</div>
        </div>
    )
}

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:

import typescript from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'

export default [
    {
        files: ['**/*.ts', '**/*.tsx'],
        languageOptions: {
            parser: tsParser,
            parserOptions: {
                project: './tsconfig.json'
            }
        },
        plugins: {
            '@typescript-eslint': typescript
        },
        rules: {
            '@typescript-eslint/no-unused-vars': 'error',
            '@typescript-eslint/no-explicit-any': 'warn'
        }
    }
]

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.

Stay Updated

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.