Build a Type-Safe Inertia.js App with Laravel, React and TypeScript

If you're reading this, you probably already know about Inertia.js. It has been a game-changer for full-stack developers, allowing us to use reactive UIs like Vue/React without the complexity of client-side routing and building/managing APIs.

While Inertia.js provides an excellent bridge between Laravel and React, achieving full type safety requires some additional setup. This article will show you how to implement end-to-end type safety in your Inertia.js application using Momentum Trail for typed routes and Spatie's Laravel Data for typed data transfer.

Set Up Typed Routes with Momentum Trail

By default, Laravel applications use Ziggy for route handling in JavaScript. However, it does not have TypeScript support.

First, let's replace Ziggy with Momentum Trail. It still uses Ziggy under the hood, but adds type-safety to the named routes.

1. Remove the Existing Ziggy Setup

In composer.json:

{
    "require": {
        /* Other packages */
        "tightenco/ziggy": "^2.0"
    }
}

In resources/views/app.blade.php (or your master layout file), remove the @routes directive:

    ...
    <!-- Scripts -->
    @routes
    @viteReactRefresh
    @vite('resources/js/app.tsx')
    @inertiaHead
  </head>
  ...

2. Install Momentum Trail

composer require based/momentum-trail
php artisan vendor:publish --tag=trail-config
 
npm install momentum-trail

3. Configure Momentum Trail

Customise your output paths if necessary in config/trail.php

return [
    'output' => [
        'routes' => resource_path('scripts/routes/routes.json'),
        'typescript' => resource_path('scripts/types/routes.d.ts'),
        'routes' => resource_path('js/routes.json'),
        'typescript' => resource_path('js/types/routes.d.ts'),
    ],
];

Generate the TypeScript route declarations using artisan:

php artisan trail:generate

Then register the routes in your React application, in resources/js/app.tsx:

import { defineRoutes } from 'momentum-trail';
import routes from './routes.json';
 
defineRoutes(routes);

Alternatively, you can replace the @routes directive in resources/views/app.blade.php with a @trail directive.

Now you can use the route and current methods in your application and get type-safe autocompletion.

...
import { route, current } from 'momentum-trail';
...
 
{
  title: 'Clients',
  url: route('clients.index'),
  isActive: current('clients.index')
}
 ...

4. Automate Routes Type Generation

You don't want to run php artisan trail:generate to generate the types every time you edit your routes file. To automate this, we’ll use the vite-plugin-watch package:

npm install -D vite-plugin-watch

In vite.config.ts

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';
import { watch } from 'vite-plugin-watch';
 
export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        react(),
        watch({
            pattern: 'routes/*.php',
            command: 'php artisan trail:generate',
        }),
    ],
});

That's it. Now your frontend is fully in sync with your Laravel route file(s).

Type-Safe Data Transfer with Laravel Data

The second piece of the puzzle is ensuring type safety for data passed between Laravel and React. For this, we will use Spatie’s Laravel Data package along with its TypeScript transformer package for Laravel.

Let's use a Client model as an example. In a typical Laravel app, you might return a client model directly in your controller like this:

public function show(Client $client)
{
    return Inertia::render('Clients/Show', [
        'client' => $client->toArray(),
    ]);
}

If your API is a bit more complicated, you might use an Eloquent API Resource:

public function show(Client $client)
{
    return Inertia::render('Clients/Show', [
        'client' => new ClientResource($client)
    ]);
}

The downside of both these approaches is that you can’t easily share the Client data types between the backend and frontend.

Let's fix that.

1. Install Laravel Data and TypeScript Transformer

composer require spatie/laravel-data
php artisan vendor:publish --provider="Spatie\LaravelData\LaravelDataServiceProvider" --tag="data-config"
 
composer require spatie/laravel-typescript-transformer
php artisan vendor:publish --provider="Spatie\LaravelTypeScriptTransformer\TypeScriptTransformerServiceProvider"

To change the default path for the generated data types, update this line in config/typescript-transformer.php:

    /*
     * The package will write the generated TypeScript to this file.
     */
 
    'output_file' => resource_path('js/types/generated.d.ts'),

2. Create a ClientData object

php artisan make:data Client

This creates a file app/Data/ClientData.php. Add the Client model's properties with their types based on the database schema. You can use constructor property promotion to keep the classes clean.

<?php
 
namespace App\Data;
 
use Carbon\CarbonImmutable;
use Spatie\LaravelData\Data;
 
class ClientData extends BaseData
{
    public function __construct(
        public string $id,
        public string $name,
        public string $email,
        public string $phone,
        #[WithCast(EnumCast::class)]
        public ClientStatus $status,
        public ?string $bio,
        public ?string $url,
        public string $address,
        public string $city,
        public string $state,
        public string $postcode,
        public CarbonImmutable $created_at,
        public CarbonImmutable $updated_at,
    ) {}
}

Note how you can also cast the $status to a native Enum, which was introduced in PHP 8.1.

<?php
 
namespace App\Enums;
 
enum ClientStatus: string
{
    case ARCHIVED = 'archived';
    case ACTIVE = 'active';
}

3. Generate types

Run the artisan command below to generate the data types in TypeScript. The --format flag keeps the generated file readable.

php artisan typescript:transform --format

Check the resulting types in types/generated.d.ts (or in your custom output path):

  declare namespace App.Data {
    export type ClientData = {
      id: string;
      name: string;
      email: string;
      phone: string;
      status: App.Enums.ClientStatus;
      bio: string | null;
      url: string | null;
      address: string;
      city: string;
      state: string;
      postcode: string;
      created_at: string;
      updated_at: string;
    };
  }
  declare namespace App.Enums {
      export type ClientStatus = 'archived' | 'active';
  }

4. Return the ClientData object in your controller:

public function show(Client $client)
{
    return Inertia::render('Clients/Show', [
        'client' => ClientData::from($client)
    ]);
}

5. Type your props in your Page component

...
type Props = {
  client: App.Data.ClientData;
};
 
export default function ClientShow({ client }: Props) {
  return (
  <>
    ...
  <>
  );
}

Type your props in your Page component with the generated ClientData type. Now you have fully typed data from the backend in your React components!

6. Automate Data Type Generation

To keep your data and enum types in sync with Laravel, we’ll use the vite-plugin-watch package again:

In vite.config.ts

...
export default defineConfig({
    plugins: [
        ...
        watch({
            pattern: 'routes/*.php',
            command: 'php artisan trail:generate',
        }),
        watch({
            pattern: 'app/{Data,Enums}/**/*.php',
            command: 'php artisan typescript:transform --format',
        }),
    ],
});

We've set up a system that automatically keeps our frontend and backend types in sync. This allows us use TypeScript in our frontend, with types that match our Laravel application's data structures.

It improves the DX significantly because we don't have to maintain types manually and also get excellent IDE autocompletion for both the frontend and backend code.


Liked this article? Share it on X