Building a CRUD app with Next.js and Laravel 12 combines the power of modern React frontend with a robust PHP backend. This guide walks you through creating a complete full-stack application that handles Create, Read, Update, and Delete operations seamlessly.
In this tutorial, you’ll learn to build a task management system using CRUD Next.js for the frontend and CRUD Laravel 12 as the API backend. This combination gives you the best of both worlds: fast, interactive user interfaces and powerful server-side functionality.
What is a CRUD App with Next.js and Laravel 12?
A CRUD Next.js and Laravel 12 application separates concerns between frontend and backend:
- Next.js Frontend: Handles user interface, routing, and client-side interactions
- Laravel 12 Backend: Provides API endpoints, database operations, and business logic
- API Communication: RESTful APIs connect both applications
CRUD operations remain the same:
- Create: Add new records
- Read: Fetch and display data
- Update: Modify existing records
- Delete: Remove records
Why Choose Next.js and Laravel 12 for CRUD Apps?
Benefits of This Stack:
- Separation of Concerns: Frontend and backend can be developed independently
- Scalability: Each part can scale separately based on needs
- Modern Technologies: Latest React features with proven PHP framework
- SEO-Friendly: Next.js provides excellent SEO capabilities
- API-First Approach: Backend can serve multiple frontends (web, mobile, etc.)
Prerequisites
Before building your CRUD app with Next.js and Laravel 12, ensure you have:
- Node.js 18+ installed
- PHP 8.2+ with Composer
- MySQL or PostgreSQL database
- Basic knowledge of React and PHP
- Understanding of REST APIs
Part 1: Setting Up Laravel 12 Backend
Step 1: Create Laravel 12 API Project
First, let’s create our CRUD Laravel 12 backend:
composer create-project laravel/laravel task-api
cd task-api
Step 2: Configure Database
Update your .env
file:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=nextjs_laravel_crud
DB_USERNAME=your_username
DB_PASSWORD=your_password
Create the database:
CREATE DATABASE nextjs_laravel_crud;
Step 3: Create Task Model and Migration
Generate the model, migration, and controller for our CRUD Laravel 12 backend:
php artisan make:model Task -mrc
Edit the migration file database/migrations/create_tasks_table.php
:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->boolean('completed')->default(false);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('tasks');
}
};
Run the migration:
php artisan migrate
Step 4: Configure Task Model
Update app/Models/Task.php
:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Task extends Model
{
protected $fillable = [
'title',
'description',
'completed',
];
protected $casts = [
'completed' => 'boolean',
];
}
Step 5: Create API Controller
Update app/Http/Controllers/TaskController.php
for our CRUD Laravel 12 API:
<?php
namespace App\Http\Controllers;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class TaskController extends Controller
{
public function index(): JsonResponse
{
$tasks = Task::latest()->get();
return response()->json([
'success' => true,
'data' => $tasks
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|max:255',
'description' => 'nullable|string',
'completed' => 'boolean'
]);
$task = Task::create($validated);
return response()->json([
'success' => true,
'data' => $task,
'message' => 'Task created successfully'
], 201);
}
public function show(Task $task): JsonResponse
{
return response()->json([
'success' => true,
'data' => $task
]);
}
public function update(Request $request, Task $task): JsonResponse
{
$validated = $request->validate([
'title' => 'required|max:255',
'description' => 'nullable|string',
'completed' => 'boolean'
]);
$task->update($validated);
return response()->json([
'success' => true,
'data' => $task,
'message' => 'Task updated successfully'
]);
}
public function destroy(Task $task): JsonResponse
{
$task->delete();
return response()->json([
'success' => true,
'message' => 'Task deleted successfully'
]);
}
}
Step 6: Install Laravel 12 API
Laravel 12 requires you to explicitly install API support:
php artisan install:api
This command will:
- Install Laravel Sanctum for API authentication
- Publish API routes
- Set up necessary middleware
- Configure API-related settings
Step 7: Set Up API Routes
Update routes/api.php
:
<?php
use App\Http\Controllers\TaskController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Route::apiResource('tasks', TaskController::class);
Step 8: Configure CORS
Since we installed the API using php artisan install:api
, CORS configuration is automatically set up with Sanctum. However, you may need to update the allowed origins.
Update config/cors.php
:
<?php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:3000'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];
Step 9: Test Laravel API
Start the Laravel server:
php artisan serve
Test the API endpoints:
# Create a task
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test Task", "description": "This is a test"}'
# Get all tasks
curl http://localhost:8000/api/tasks
Part 2: Setting Up Next.js Frontend
Step 1: Create Next.js Project
Create your CRUD Next.js frontend:
npx create-next-app@latest task-frontend
cd task-frontend
Choose these options:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
src/
directory: Yes- App Router: Yes
Step 2: Install Required Packages
Install additional packages for our CRUD Next.js app:
npm install axios react-hook-form @hookform/resolvers yup
Step 3: Create API Service
Create src/lib/api.js
:
import axios from 'axios';
const API_BASE_URL = 'http://localhost:8000/api';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
export const taskApi = {
// Get all tasks
getTasks: async () => {
const response = await api.get('/tasks');
return response.data;
},
// Create new task
createTask: async (taskData) => {
const response = await api.post('/tasks', taskData);
return response.data;
},
// Get single task
getTask: async (id) => {
const response = await api.get(`/tasks/${id}`);
return response.data;
},
// Update task
updateTask: async (id, taskData) => {
const response = await api.put(`/tasks/${id}`, taskData);
return response.data;
},
// Delete task
deleteTask: async (id) => {
const response = await api.delete(`/tasks/${id}`);
return response.data;
},
};
Step 4: Create Task Components
Create src/components/TaskForm.js
:
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
export default function TaskForm({ task, onSubmit, onCancel }) {
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
title: task?.title || '',
description: task?.description || '',
completed: task?.completed || false,
},
});
const onFormSubmit = async (data) => {
setIsLoading(true);
try {
await onSubmit(data);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
Title
</label>
<input
type="text"
id="title"
{...register('title', { required: 'Title is required' })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
{errors.title && (
<p className="mt-1 text-sm text-red-600">{errors.title.message}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
rows={3}
{...register('description')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<div className="flex items-center">
<input
id="completed"
type="checkbox"
{...register('completed')}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="completed" className="ml-2 block text-sm text-gray-900">
Mark as completed
</label>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{isLoading ? 'Saving...' : (task ? 'Update' : 'Create')} Task
</button>
</div>
</form>
);
}
Create src/components/TaskList.js
:
'use client';
import { useState } from 'react';
export default function TaskList({ tasks, onEdit, onDelete, onToggleComplete }) {
const [loadingId, setLoadingId] = useState(null);
const handleToggleComplete = async (task) => {
setLoadingId(task.id);
try {
await onToggleComplete(task);
} finally {
setLoadingId(null);
}
};
const handleDelete = async (taskId) => {
if (window.confirm('Are you sure you want to delete this task?')) {
setLoadingId(taskId);
try {
await onDelete(taskId);
} finally {
setLoadingId(null);
}
}
};
if (tasks.length === 0) {
return (
<div className="text-center py-12">
<h3 className="mt-2 text-sm font-medium text-gray-900">No tasks</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating a new task.</p>
</div>
);
}
return (
<div className="space-y-4">
{tasks.map((task) => (
<div
key={task.id}
className="bg-white shadow rounded-lg p-6"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<input
type="checkbox"
checked={task.completed}
onChange={() => handleToggleComplete(task)}
disabled={loadingId === task.id}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<div>
<h3 className={`text-sm font-medium ${task.completed ? 'line-through text-gray-500' : 'text-gray-900'}`}>
{task.title}
</h3>
{task.description && (
<p className={`text-sm ${task.completed ? 'line-through text-gray-400' : 'text-gray-500'}`}>
{task.description}
</p>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onEdit(task)}
className="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
Edit
</button>
<button
onClick={() => handleDelete(task.id)}
disabled={loadingId === task.id}
className="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 disabled:opacity-50"
>
{loadingId === task.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
))}
</div>
);
}
Step 5: Create Main Page
Update src/app/page.js
:
'use client';
import { useState, useEffect } from 'react';
import { taskApi } from '@/lib/api';
import TaskList from '@/components/TaskList';
import TaskForm from '@/components/TaskForm';
export default function Home() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showForm, setShowForm] = useState(false);
const [editingTask, setEditingTask] = useState(null);
useEffect(() => {
fetchTasks();
}, []);
const fetchTasks = async () => {
try {
setLoading(true);
const response = await taskApi.getTasks();
setTasks(response.data);
} catch (err) {
setError('Failed to fetch tasks');
console.error('Error fetching tasks:', err);
} finally {
setLoading(false);
}
};
const handleCreateTask = async (taskData) => {
try {
const response = await taskApi.createTask(taskData);
setTasks([response.data, ...tasks]);
setShowForm(false);
} catch (err) {
setError('Failed to create task');
console.error('Error creating task:', err);
}
};
const handleUpdateTask = async (taskData) => {
try {
const response = await taskApi.updateTask(editingTask.id, taskData);
setTasks(tasks.map(task =>
task.id === editingTask.id ? response.data : task
));
setEditingTask(null);
setShowForm(false);
} catch (err) {
setError('Failed to update task');
console.error('Error updating task:', err);
}
};
const handleDeleteTask = async (taskId) => {
try {
await taskApi.deleteTask(taskId);
setTasks(tasks.filter(task => task.id !== taskId));
} catch (err) {
setError('Failed to delete task');
console.error('Error deleting task:', err);
}
};
const handleToggleComplete = async (task) => {
try {
const response = await taskApi.updateTask(task.id, {
...task,
completed: !task.completed
});
setTasks(tasks.map(t =>
t.id === task.id ? response.data : t
));
} catch (err) {
setError('Failed to update task');
console.error('Error updating task:', err);
}
};
const handleEdit = (task) => {
setEditingTask(task);
setShowForm(true);
};
const handleCancel = () => {
setShowForm(false);
setEditingTask(null);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-indigo-500 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading tasks...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">
Task Manager
</h1>
<p className="mt-2 text-gray-600">
Built with Next.js and Laravel 12 CRUD
</p>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
{error}
<button
onClick={() => setError(null)}
className="absolute top-0 bottom-0 right-0 px-4 py-3"
>
×
</button>
</div>
)}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">
Your Tasks ({tasks.length})
</h2>
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Add New Task
</button>
</div>
{showForm && (
<div className="mb-6 p-6 bg-gray-50 rounded-lg">
<h3 className="text-lg font-medium text-gray-900 mb-4">
{editingTask ? 'Edit Task' : 'Create New Task'}
</h3>
<TaskForm
task={editingTask}
onSubmit={editingTask ? handleUpdateTask : handleCreateTask}
onCancel={handleCancel}
/>
</div>
)}
<TaskList
tasks={tasks}
onEdit={handleEdit}
onDelete={handleDeleteTask}
onToggleComplete={handleToggleComplete}
/>
</div>
</div>
</div>
);
}
Step 6: Update Layout
Update src/app/layout.js
:
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'Task Manager - CRUD App with Next.js and Laravel 12',
description: 'A complete CRUD application built with Next.js frontend and Laravel 12 backend API',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
Part 3: Testing Your CRUD App
Step 1: Start Both Servers
Terminal 1 (Laravel):
cd task-api
php artisan serve
Terminal 2 (Next.js):
cd task-frontend
npm run dev
Step 2: Test CRUD Operations
Visit http://localhost:3000
and test:
- Create: Add new tasks using the form
- Read: View all tasks in the list
- Update: Edit existing tasks and toggle completion
- Delete: Remove tasks with confirmation
Advanced Features for Your CRUD Next.js Laravel 12 App
1. Add Search Functionality
Update your Laravel controller:
public function index(Request $request): JsonResponse
{
$query = Task::query();
if ($request->has('search')) {
$query->where('title', 'like', '%' . $request->search . '%')
->orWhere('description', 'like', '%' . $request->search . '%');
}
$tasks = $query->latest()->get();
return response()->json([
'success' => true,
'data' => $tasks
]);
}
2. Add Pagination
Implement pagination in Laravel:
public function index(Request $request): JsonResponse
{
$tasks = Task::latest()->paginate(10);
return response()->json([
'success' => true,
'data' => $tasks->items(),
'pagination' => [
'current_page' => $tasks->currentPage(),
'last_page' => $tasks->lastPage(),
'per_page' => $tasks->perPage(),
'total' => $tasks->total(),
]
]);
}
3. Add Authentication
Since we already installed Laravel Sanctum with php artisan install:api
, we can easily add authentication to our CRUD Laravel 12 API.
Create an authentication controller:
php artisan make:controller AuthController
Update app/Http/Controllers/AuthController.php
:
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'success' => true,
'data' => [
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
]
], 201);
}
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'success' => true,
'data' => [
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
]
]);
}
}
Add authentication routes to routes/api.php
:
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\TaskController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
Route::apiResource('tasks', TaskController::class);
});
Error Handling Best Practices
Laravel Error Handling
Create app/Http/Middleware/HandleApiErrors.php
:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HandleApiErrors
{
public function handle(Request $request, Closure $next): Response
{
try {
return $next($request);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'An error occurred',
'error' => $e->getMessage()
], 500);
}
}
}
Next.js Error Handling
Create src/hooks/useErrorHandler.js
:
import { useState } from 'react';
export function useErrorHandler() {
const [error, setError] = useState(null);
const handleError = (error) => {
console.error('Error:', error);
setError(error.message || 'An unexpected error occurred');
};
const clearError = () => setError(null);
return { error, handleError, clearError };
}
Performance Optimization Tips
Laravel Optimization
- Use API Resources:
php artisan make:resource TaskResource
- Add Database Indexes:
$table->index(['title', 'completed']);
- Implement Caching:
$tasks = Cache::remember('tasks', 300, function () {
return Task::latest()->get();
});
Next.js Optimization
- Use React Query for better data fetching:
npm install @tanstack/react-query
- Implement Loading States:
const [loading, setLoading] = useState(false);
- Add Error Boundaries:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
// ... error boundary logic
}
Security Considerations
Laravel Security
- Rate Limiting:
Route::middleware('throttle:60,1')->group(function () {
Route::apiResource('tasks', TaskController::class);
});
- Input Validation:
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
]);
- CORS Configuration:
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
Next.js Security
- Environment Variables:
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
- Input Sanitization:
import DOMPurify from 'dompurify';
const cleanInput = DOMPurify.sanitize(userInput);
Deployment Guide
Deploy Laravel to Production
- Optimize Laravel:
php artisan config:cache
php artisan route:cache
php artisan view:cache
- Set Production Environment:
APP_ENV=production
APP_DEBUG=false
Deploy Next.js to Vercel
- Build the Application:
npm run build
-
Configure Environment Variables in Vercel dashboard
-
Deploy:
npx vercel --prod
Common Issues and Solutions
CORS Issues
Problem: Cross-origin requests blocked Solution: Configure CORS properly in Laravel and ensure frontend URL is allowed
API Connection Issues
Problem: Cannot connect to Laravel API Solution: Check if both servers are running and API base URL is correct
Database Connection Issues
Problem: Database connection failed Solution: Verify database credentials and ensure database server is running
Testing Your CRUD Application
Laravel Testing
Create tests/Feature/TaskApiTest.php
:
<?php
namespace Tests\Feature;
use App\Models\Task;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TaskApiTest extends TestCase
{
use RefreshDatabase;
public function test_can_create_task()
{
$response = $this->postJson('/api/tasks', [
'title' => 'Test Task',
'description' => 'Test Description'
]);
$response->assertStatus(201)
->assertJson([
'success' => true,
'data' => [
'title' => 'Test Task'
]
]);
}
public function test_can_fetch_tasks()
{
Task::factory()->count(3)->create();
$response = $this->getJson('/api/tasks');
$response->assertStatus(200)
->assertJson([
'success' => true
]);
}
}
Next.js Testing
Install testing libraries:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest-environment-jsdom
Create __tests__/TaskList.test.js
:
import { render, screen } from '@testing-library/react';
import TaskList from '../src/components/TaskList';
const mockTasks = [
{ id: 1, title: 'Test Task', description: 'Test Description', completed: false }
];
test('renders task list', () => {
render(
<TaskList
tasks={mockTasks}
onEdit={() => {}}
onDelete={() => {}}
onToggleComplete={() => {}}
/>
);
expect(screen.getByText('Test Task')).toBeInTheDocument();
});
Conclusion
Building a CRUD app with Next.js and Laravel 12 provides a powerful, scalable solution for modern web applications. This combination offers:
- Separation of Concerns: Clear division between frontend and backend
- Modern Technologies: Latest React and PHP frameworks
- API-First Approach: Flexible architecture for future expansion
- Developer Experience: Excellent tooling and development workflow
The CRUD Next.js frontend provides excellent user experience with fast, interactive interfaces, while the CRUD Laravel 12 backend offers robust API endpoints with proper validation and error handling.