Laravel 12 + Inertia + React Full-Stack Tutorial

Laravel 12 + Inertia + React Full-Stack Tutorial

Laravel 12 + Inertia + React Full-Stack Tutorial

In this tutorial, we will build a simple CRUD application to manage posts using Laravel 12 for the backend and React JS for the frontend using Inertia.js. 

Preview: List Posts
Preview: Create Posts
Preview: Update Posts
Preview: Delete Posts

What You'll Build:

Post CRUD app (Create, Read, Update, Delete) using:

  • Laravel 12 (Backend)

  • Inertia.js (Bridge between Laravel and React)

  • React.js 18 (Frontend)

Requirements

Make sure you have the following installed:

  • PHP 8.2+

  • Composer

  • Node.js + npm

  • Laravel CLI

  • MySQL/PostgreSQL

  • A web browser (Chrome, Firefox, etc.)

Step 1: Install Laravel 12

composer create-project laravel/laravel laravel-react-crud

Update .env to connect with your database:

DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=react_crud DB_USERNAME=root DB_PASSWORD=

Step 2: Install Breeze with Inertia.js + React

composer require laravel/breeze --dev php artisan breeze:install react npm install

This installs Breeze with Inertia + React stack and sets up default auth scaffolding.

Step 3: Create Post Model and Migration

php artisan make:model Post -mcr

In database/migrations/xxxx_create_posts_table.php, Define the schema:

Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->text('body'); $table->timestamps(); });

Run the migration:

php artisan migrate

Update the model app/Models/Post.php:

protected $fillable = ['title', 'body'];

Step 4: Create Post Controller

Add logic to app/Http/Controllers/PostController.php:

namespace App\Http\Controllers; use App\Models\Post; use Illuminate\Http\Request; use Inertia\Inertia; class PostController extends Controller { public function index() { return Inertia::render('Posts/Index', [ 'posts' => Post::all() ]); } public function create() { return Inertia::render('Posts/Create'); } public function store(Request $request) { $request->validate([ 'title' => 'required', 'body' => 'required', ]); Post::create($request->all()); return redirect()->route('posts.index'); } public function edit(Post $post) { return Inertia::render('Posts/Edit', [ 'post' => $post ]); } public function update(Request $request, Post $post) { $request->validate([ 'title' => 'required', 'body' => 'required', ]); $post->update($request->all()); return redirect()->route('posts.index'); } public function destroy(Post $post) { $post->delete(); return redirect()->route('posts.index'); } }

Step 5: Define Routes

In routes/web.php:

use App\Http\Controllers\PostController; Route::middleware(['auth', 'verified'])->group(function () { Route::resource('posts', PostController::class); });

Step 6: Create React Components

Create a folder:

mkdir resources/js/Pages/Posts

📄 resources/js/Pages/Posts/Index.jsx

import React, { useState } from 'react'; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head, Link, router } from '@inertiajs/react'; export default function Index({ auth, posts }) { const [showDeleteModal, setShowDeleteModal] = useState(false); const [postToDelete, setPostToDelete] = useState(null); const openDeleteModal = (id) => { setPostToDelete(id); setShowDeleteModal(true); }; const cancelDelete = () => { setShowDeleteModal(false); setPostToDelete(null); }; const confirmDelete = () => { if (postToDelete) { router.delete(`/posts/${postToDelete}`, { onSuccess: () => { setShowDeleteModal(false); setPostToDelete(null); }, }); } }; return ( <AuthenticatedLayout user={auth.user}> <Head title="Posts" /> <div className="py-12"> <div className="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6"> <div className="flex justify-between items-center mb-4"> <h2 className="text-xl font-bold">Post List</h2> <Link href="/posts/create" className="bg-blue-600 text-white text-sm px-2.5 py-1.5 rounded hover:bg-blue-700" > ➕ Create </Link> </div> <table className="w-full table-auto border"> <thead> <tr className="bg-gray-100 text-left"> <th className="border p-2 w-[8%]">No</th> <th className="border p-2">Title</th> <th className="border p-2">Body</th> <th className="border p-2 text-center w-[20%]">Actions</th> </tr> </thead> <tbody> {posts.length === 0 ? ( <tr> <td colSpan="4" className="border p-4 text-center text-gray-500"> No posts available. </td> </tr> ) : ( posts.map((post, index) => ( <tr key={post.id} className="hover:bg-gray-50"> <td className="border p-2 text-center">{index + 1}</td> <td className="border p-2">{post.title}</td> <td className="border p-2">{post.body}</td> <td className="border p-2 text-center space-x-2"> <Link href={`/posts/${post.id}/edit`} className="text-xs text-white bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded" > ✏️ Edit </Link> <button onClick={() => openDeleteModal(post.id)} className="text-xs text-white bg-red-600 hover:bg-red-700 px-2 py-1 rounded" > 🗑️ Delete </button> </td> </tr> )) )} </tbody> </table> </div> </div> </div> {/* Delete Confirmation Modal */} {showDeleteModal && ( <div className="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50"> <div className="bg-white rounded-lg shadow-md w-full max-w-sm p-6"> <h2 className="text-lg font-semibold text-gray-800 mb-4 text-center"> Confirm Deletion </h2> <p className="text-gray-600 mb-6 text-center"> Are you sure you want to delete this post? </p> <div className="flex justify-center space-x-2"> <button onClick={cancelDelete} className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold py-1 px-4 rounded" > Cancel </button> <button onClick={confirmDelete} className="bg-red-500 hover:bg-red-700 text-white font-semibold py-1 px-4 rounded" > Yes, Delete </button> </div> </div> </div> )} </AuthenticatedLayout> ); }

resources/js/Pages/Posts/Create.jsx

import React from 'react'; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head, Link, useForm } from '@inertiajs/react'; export default function Create({ auth }) { const { data, setData, post, processing, errors } = useForm({ title: '', body: '', }); const submit = (e) => { e.preventDefault(); post('/posts'); }; return ( <AuthenticatedLayout user={auth.user} header={ <h2 className="font-semibold text-xl text-gray-800 leading-tight"> Create Posts </h2> } > <Head title="Create Post" /> <div className="py-12"> <div className="max-w-4xl mx-auto px-2 sm:px-4 lg:px-5"> <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6"> {/* Back Button */} <Link href="/posts" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded hover:bg-gray-300" > 🔙 <span>Back</span> </Link> {/* Form */} <form onSubmit={submit} className="space-y-6 mt-6"> {/* Title Field */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">Title</label> <input type="text" value={data.title} onChange={(e) => setData('title', e.target.value)} className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200" /> {errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>} </div> {/* Body Field */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">Body</label> <textarea value={data.body} onChange={(e) => setData('body', e.target.value)} className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200" /> {errors.body && <p className="text-red-500 text-sm mt-1">{errors.body}</p>} </div> {/* Submit Button */} <button type="submit" disabled={processing} className={`bg-green-600 text-white text-sm px-4 py-2 rounded hover:bg-green-700 transition ${ processing ? 'opacity-50 cursor-not-allowed' : '' }`} > ✅ Submit </button> </form> </div> </div> </div> </AuthenticatedLayout> ); }

✏️ resources/js/Pages/Posts/Edit.jsx

import React from 'react'; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head, Link, useForm } from '@inertiajs/react'; export default function Edit({ auth, post }) { const { data, setData, put, processing, errors } = useForm({ title: post.title || '', body: post.body || '', }); const submit = (e) => { e.preventDefault(); put(`/posts/${post.id}`); }; return ( <AuthenticatedLayout user={auth.user} header={ <h2 className="font-semibold text-xl text-gray-800 leading-tight"> Edit Post </h2> } > <Head title="Edit Post" /> <div className="py-12"> <div className="max-w-4xl mx-auto px-2 sm:px-4 lg:px-5"> <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6"> <Link href="/posts" className="inline-flex items-center px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded hover:bg-gray-300" > 🔙 Back </Link> <form onSubmit={submit} className="space-y-4 mt-6"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">Title</label> <input type="text" value={data.title} onChange={(e) => setData('title', e.target.value)} className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200" /> {errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>} </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">Body</label> <textarea value={data.body} onChange={(e) => setData('body', e.target.value)} className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200" /> {errors.body && <p className="text-red-500 text-sm mt-1">{errors.body}</p>} </div> <button type="submit" disabled={processing} className={`bg-blue-600 text-white text-sm px-4 py-2 rounded hover:bg-blue-700 transition ${ processing ? 'opacity-50 cursor-not-allowed' : '' }`} > 💾 Update </button> </form> </div> </div> </div> </AuthenticatedLayout> ); }

Step 7: Add Posts to Navigation Menu

Open resources/js/Layouts/AuthenticatedLayout.jsx.  , and inside your navigation area:

<NavLink href={route('posts.index')} active={route().current('posts.*')}> Posts </NavLink>

Step 8: Open or create bootstrap/app.php

If the file does not exist, create it inside your project folder under /bootstrap.

Step 9: Replace or add the following full content in bootstrap/app.php

<?php use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Exceptions; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__ . '/../routes/web.php', commands: __DIR__ . '/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { // Here you append your middleware to the 'web' group $middleware->web(append: [ \App\Http\Middleware\HandleInertiaRequests::class, \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { // You can customize exception handling here, or leave empty }) ->create();

Final Touch

Run the app:

php artisan serve npm run dev

Visit: http://localhost:8000/posts (after login/register)

directory structure with clear indentation and layout:

Laravel + Inertia + React App ├── app/ │ ├── Http/ │ │ ├── Controllers/ │ │ │ ├── PostController.php │ │ │ └── Auth/ │ │ └── Middleware/ │ ├── Models/ │ │ └── Post.php │ ├── Policies/ │ └── Providers/ │ ├── routes/ │ ├── web.php │ └── api.php │ ├── resources/ │ ├── js/ │ │ ├── bootstrap.js │ │ ├── App.jsx │ │ ├── Pages/ │ │ │ ├── Dashboard.jsx │ │ │ └── Posts/ │ │ │ ├── Index.jsx │ │ │ ├── Create.jsx │ │ │ └── Edit.jsx │ │ ├── Components/ │ │ │ ├── NavLink.jsx │ │ │ ├── Pagination.jsx │ │ │ └── FormInput.jsx │ │ └── Layouts/ │ │ ├── AuthenticatedLayout.jsx │ │ └── GuestLayout.jsx │ └── views/ │ └── app.blade.php │ ├── public/ │ └── images/ │ ├── database/ │ ├── factories/ │ │ └── PostFactory.php │ ├── migrations/ │ │ └── 2024_01_01_create_posts_table.php │ └── seeders/ │ └── PostSeeder.php

Laravel Backend (Server-Side)

app/ │ ├── Http/ │ ├── Controllers/ │ │ ├── PostController.php │ │ └── Auth/ │ └── Middleware/ │ ├── Models/ │ └── Post.php │ ├── Policies/ │ └── Providers/

Routes

routes/ ├── web.php # Inertia (React) frontend routes ├── api.php # Optional API routes

React + Inertia Frontend (Client-Side)

resources/ └── js/ ├── bootstrap.js # Inertia and React setup ├── App.jsx # Main React root component (optional) ├── Pages/ # Inertia pages (one file = one route) │ ├── Dashboard.jsx │ └── Posts/ │ ├── Index.jsx # List of posts │ ├── Create.jsx # Create post form │ └── Edit.jsx # Edit post form │ ├── Components/ # Reusable components │ ├── NavLink.jsx │ ├── Pagination.jsx │ └── FormInput.jsx │ └── Layouts/ ├── AuthenticatedLayout.jsx └── GuestLayout.jsx

Views

resources/views/ └── app.blade.php # Blade template for Inertia entrypoint

Public Assets

public/ └── images/ # Optional for uploaded images

Database

database/ ├── factories/ │ └── PostFactory.php ├── migrations/ │ └── 2024_01_01_create_posts_table.php └── seeders/ └── PostSeeder.php

Summary

You’ve built a full Post CRUD using:

  • Laravel 12 as the backend

  • Inertia.js to connect the back-end and front-end

  • React 18 for UI components

Souy Soeng

Souy Soeng

Hi there 👋, I’m Soeng Souy (StarCode Kh)
-------------------------------------------
🌱 I’m currently creating a sample Laravel and React Vue Livewire
👯 I’m looking to collaborate on open-source PHP & JavaScript projects
💬 Ask me about Laravel, MySQL, or Flutter
⚡ Fun fact: I love turning ☕️ into code!

Post a Comment

CAN FEEDBACK
close