ix
ix

informatix

PL version Book a discovery call
Home / Blog / Inertia.js CRUD Tutorial — Laravel 12 + React Step by Step [...
• 10 min read •

Inertia.js CRUD Tutorial — Laravel 12 + React Step by Step [2026]

A complete CRUD module with Laravel 12, React and Inertia.js — list, create, edit, update and delete without a REST API. Routes, controller, validation, authorization and tests, step by step.

Inertia.js CRUD Tutorial — Laravel 12 + React Step by Step [2026]
Web development

This tutorial builds a working CRUD module with Laravel 12, React and Inertia.js — list, create, edit, update and delete — without a REST API. It is the fastest way to see how routing, controllers, validation and React pages fit together in a real application. The code is intentionally small; the patterns scale to larger products.

Key takeaway: The short version — one Laravel resource controller renders Inertia pages, one React page per action, useForm handles submissions and Laravel validation returns errors automatically. No API routes, no tokens, no second repository.

What we build

A simple order management module

  • /orders — paginated list with a search field;
  • /orders/create — create form;
  • /orders/{order}/edit — edit form;
  • delete with confirmation;
  • validation errors displayed next to fields;
  • flash messages after success.

1. Create the Laravel application

laravel new inertia-crud --react
cd inertia-crud
composer install
npm install

The React starter kit already includes Inertia, React, Vite and authentication. If you are adding this to an existing app, follow the installation guide first.

2. Create the model and migration

php artisan make:model Order -m
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('customer_id')->constrained()->cascadeOnDelete();
    $table->decimal('total', 10, 2);
    $table->string('status')->default('pending');
    $table->text('notes')->nullable();
    $table->timestamps();

    $table->index(['status', 'created_at']);
});
class Order extends Model
{
    protected $fillable = [
        'customer_id',
        'total',
        'status',
        'notes',
    ];

    protected $casts = [
        'total' => 'decimal:2',
    ];

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }
}

Run the migration

php artisan migrate

3. Create the Form Requests

php artisan make:request StoreOrderRequest
php artisan make:request UpdateOrderRequest
public function rules(): array
{
    return [
        'customer_id' => ['required', 'exists:customers,id'],
        'total' => ['required', 'numeric', 'min:0'],
        'status' => ['required', 'in:pending,paid,shipped,cancelled'],
        'notes' => ['nullable', 'string', 'max:2000'],
    ];
}

Use the same rules in both requests; the update request can reuse the store rules.

4. Create the controller

php artisan make:controller OrderController --resource
class OrderController extends Controller
{
    public function index(Request $request)
    {
        $orders = Order::query()
            ->with('customer:id,name')
            ->when($request->search, fn ($query, $search) =>
                $query->whereHas('customer', fn ($q) =>
                    $q->where('name', 'like', "%{$search}%")
                )
            )
            ->latest()
            ->paginate(20)
            ->withQueryString();

        return Inertia::render('Orders/Index', [
            'orders' => OrderResource::collection($orders),
            'filters' => $request->only('search'),
        ]);
    }

    public function create()
    {
        return Inertia::render('Orders/Create', [
            'customers' => Customer::select('id', 'name')->orderBy('name')->get(),
        ]);
    }

    public function store(StoreOrderRequest $request)
    {
        $order = Order::create($request->validated());

        return redirect()
            ->route('orders.index')
            ->with('success', 'Order created.');
    }

    public function edit(Order $order)
    {
        return Inertia::render('Orders/Edit', [
            'order' => new OrderResource($order->load('customer:id,name')),
            'customers' => Customer::select('id', 'name')->orderBy('name')->get(),
        ]);
    }

    public function update(UpdateOrderRequest $request, Order $order)
    {
        $order->update($request->validated());

        return redirect()
            ->route('orders.index')
            ->with('success', 'Order updated.');
    }

    public function destroy(Order $order)
    {
        $order->delete();

        return redirect()
            ->route('orders.index')
            ->with('success', 'Order deleted.');
    }
}

Create an API Resource so the list sends only the fields the UI needs

php artisan make:resource OrderResource
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'total' => $this->total,
        'status' => $this->status,
        'notes' => $this->notes,
        'customer' => $this->whenLoaded('customer', fn () => [
            'id' => $this->customer->id,
            'name' => $this->customer->name,
        ]),
        'created_at' => $this->created_at->toIso8601String(),
    ];
}

5. Register the routes

use App\Http\Controllers\OrderController;

Route::middleware('auth')->group(function () {
    Route::resource('orders', OrderController::class)
        ->except(['show']);
});

Route::resource creates the routes and names (orders.index, orders.create, orders.store, orders.edit, orders.update, orders.destroy).

6. Build the list page

resources/js/Pages/Orders/Index.jsx

import { Link, router } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Index({ orders, filters }) {
    function search(event) {
        event.preventDefault();
        router.get('/orders', { search: event.target.search.value }, {
            preserveState: true,
            replace: true,
        });
    }

    return (
        <AuthenticatedLayout>
            <div className="flex items-center justify-between">
                <h1 className="text-2xl font-bold">Orders</h1>
                <Link href="/orders/create" className="btn">New order</Link>
            </div>

            <form onSubmit={search} className="mt-4">
                <input
                    name="search"
                    defaultValue={filters.search ?? ''}
                    placeholder="Search by customer"
                />
            </form>

            <table className="mt-6 w-full">
                <thead>
                    <tr>
                        <th>ID</th>
                        <th>Customer</th>
                        <th>Total</th>
                        <th>Status</th>
                        <th />
                    </tr>
                </thead>
                <tbody>
                    {orders.data.map((order) => (
                        <tr key={order.id}>
                            <td>#{order.id}</td>
                            <td>{order.customer?.name}</td>
                            <td>{order.total}</td>
                            <td>{order.status}</td>
                            <td>
                                <Link href={`/orders/${order.id}/edit`}>Edit</Link>
                            </td>
                        </tr>
                    ))}
                </tbody>
            </table>

            <div className="mt-4">
                {orders.links.map((link) => (
                    <Link
                        key={link.label}
                        href={link.url}
                        dangerouslySetInnerHTML={{ __html: link.label }}
                        className={link.active ? 'font-bold' : ''}
                    />
                ))}
            </div>
        </AuthenticatedLayout>
    );
}

7. Build the create and edit pages

resources/js/Pages/Orders/Create.jsx

import { useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Create({ customers }) {
    const { data, setData, post, processing, errors, reset } = useForm({
        customer_id: '',
        total: '',
        status: 'pending',
        notes: '',
    });

    function submit(event) {
        event.preventDefault();
        post('/orders', {
            onSuccess: () => reset(),
        });
    }

    return (
        <AuthenticatedLayout>
            <h1 className="text-2xl font-bold">New order</h1>

            <form onSubmit={submit} className="mt-6 space-y-4">
                <div>
                    <select
                        value={data.customer_id}
                        onChange={(e) => setData('customer_id', e.target.value)}
                    >
                        <option value="">Select a customer</option>
                        {customers.map((customer) => (
                            <option key={customer.id} value={customer.id}>
                                {customer.name}
                            </option>
                        ))}
                    </select>
                    {errors.customer_id && <p>{errors.customer_id}</p>}
                </div>

                <div>
                    <input
                        type="number"
                        step="0.01"
                        value={data.total}
                        onChange={(e) => setData('total', e.target.value)}
                    />
                    {errors.total && <p>{errors.total}</p>}
                </div>

                <div>
                    <select
                        value={data.status}
                        onChange={(e) => setData('status', e.target.value)}
                    >
                        <option value="pending">Pending</option>
                        <option value="paid">Paid</option>
                        <option value="shipped">Shipped</option>
                        <option value="cancelled">Cancelled</option>
                    </select>
                    {errors.status && <p>{errors.status}</p>}
                </div>

                <div>
                    <textarea
                        value={data.notes}
                        onChange={(e) => setData('notes', e.target.value)}
                    />
                    {errors.notes && <p>{errors.notes}</p>}
                </div>

                <button disabled={processing}>Save</button>
            </form>
        </AuthenticatedLayout>
    );
}

resources/js/Pages/Orders/Edit.jsx is the same form with put and initial values

const { data, setData, put, processing, errors } = useForm({
    customer_id: order.customer?.id ?? '',
    total: order.total,
    status: order.status,
    notes: order.notes ?? '',
});

function submit(event) {
    event.preventDefault();
    put(`/orders/${order.id}`);
}

8. Add delete with confirmation

import { router } from '@inertiajs/react';

function destroy(order) {
    if (confirm(`Delete order #${order.id}?`)) {
        router.delete(`/orders/${order.id}`, {
            preserveScroll: true,
        });
    }
}

9. Show flash messages

Share flash data in HandleInertiaRequests

'flash' => [
    'success' => fn () => $request->session()->get('success'),
    'error' => fn () => $request->session()->get('error'),
],

Render it in the layout

const { flash } = usePage().props;

{flash.success && <div className="alert">{flash.success}</div>}

10. Add authorization

Create a policy and authorize the actions

php artisan make:policy OrderPolicy --model=Order
public function update(User $user, Order $order): bool
{
    return $user->id === $order->user_id || $user->isAdmin();
}
$this->authorize('update', $order);

Hide the edit button in React for a better UX, but keep the server-side check.

11. Test the CRUD

public function test_user_can_create_an_order(): void
{
    $user = User::factory()->create();
    $customer = Customer::factory()->create();

    $this->actingAs($user)
        ->post('/orders', [
            'customer_id' => $customer->id,
            'total' => 199.99,
            'status' => 'pending',
        ])
        ->assertRedirect('/orders');

    $this->assertDatabaseHas('orders', [
        'customer_id' => $customer->id,
        'total' => 199.99,
    ]);
}

public function test_orders_index_renders_the_page(): void
{
    $this->actingAs(User::factory()->create());

    $this->get('/orders')
        ->assertInertia(fn (Assert $page) => $page
            ->component('Orders/Index')
            ->has('orders.data')
        );
}

12. What to build next

  • search and filters with partial reloads;
  • sorting and pagination;
  • bulk actions;
  • file uploads;
  • role-based permissions;
  • activity log;
  • export to CSV;
  • tests for every action.

FAQ

Do I need an API for CRUD in Inertia?

No. The controller returns Inertia pages and receives form submissions directly. An API is only needed for a second client.

How do I handle validation errors?

Laravel returns a 422 response with the errors; Inertia maps them to the errors object from useForm.

How do I redirect after create or update?

Return redirect()->route('orders.index')->with('success', '...'). Inertia follows the redirect and renders the target page.

How do I delete a record?

Use router.delete() and a Laravel destroy action. Add a confirmation dialog and preserveScroll.

How do I paginate?

Use paginate() in the controller and render orders.links in React. Inertia preserves the query string with withQueryString().

Can I use a UI library?

Yes. Inertia is agnostic. Use Tailwind, shadcn/ui, MUI or anything else; the data flow stays the same.

How do I test Inertia CRUD?

Use assertInertia for page components and props, and standard Laravel assertions for database changes and redirects.

Sources


Need a custom Laravel + React module? Check our Laravel development services or hire a Laravel developer.

Read more: Inertia.js with Laravel and React, Inertia.js forms and validation, Inertia.js authentication and testing Inertia.js applications.

KS

Krzysztof Słonina

Full stack developer and owner of Inoventis Solutions (informatix). Since 2009 he has built Laravel/PHP web applications, e-commerce integrations and process automations — including OneCRMApp and FamilyTimelineApp, both running on Laravel, React and Inertia.js.

About the author

Related posts