Next.js + Laravel: The Stack That Actually Scales (And Why I'll Never Go Back)
Hot take: Next.js API routes are fine for prototypes. Laravel-only SPAs are fine for internal tools. But if you're building something that needs to scale AND ship fast? You want both.
I've shipped 4 production apps using Next.js frontend + Laravel backend. Toytopia (e-commerce) does 2,000+ daily users. Sayartak (on-demand services) handles real-time updates for 10,000+ users. Zero downtime. Zero regrets.
Why Not Full-Stack Next.js?#
I tried. I really did. Server Actions are cool. API routes work. Until they don't.
Problems I hit at scale:
1. No built-in queue system - Background jobs are painful
2. Database migrations suck - Prisma is... fine. Not great.
3. Authentication complexity - NextAuth is powerful but overcomplicated
4. No mature admin panels - Laravel Nova/Filament beat anything in Node
5. Payment gateway SDKs prefer PHP - HyperPay, MEPS, local gateways
Why Not Laravel-Only SPA?#
Laravel + Inertia is beautiful. But:
1. SEO is harder - Yes, Inertia SSR exists. It's not as smooth as Next.js
2. React ecosystem limited - Can't use Vercel's AI SDK, Framer Motion gets weird
3. Image optimization is manual - Next.js Image component is undefeated
4. Harder to get frontend talent - Most React devs know Next.js, not Inertia
The Sweet Spot: Hybrid Architecture#
Use each tool for what it's best at:
Architecture Overview
┌─────────────────────────────────────────┐
│ Next.js Frontend │
│ ┌─────────────────────────────┐ │
│ │ Server Components (SSR) │ │
│ │ - SEO-critical pages │ │
│ │ - Initial data fetching │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ Client Components │ │
│ │ - Interactive UI │ │
│ │ - Real-time updates │ │
│ └─────────────────────────────┘ │
└─────────────────┬───────────────────────┘
│
│ HTTP/REST API
│
┌─────────────────▼───────────────────────┐
│ Laravel Backend │
│ ┌─────────────────────────────┐ │
│ │ API Controllers │ │
│ │ - Business logic │ │
│ │ - Data validation │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ Background Jobs │ │
│ │ - Email sending │ │
│ │ - Payment processing │ │
│ │ - Data sync (NetSuite) │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ WebSocket Server │ │
│ │ - Real-time broadcasting │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ MySQL / Redis │
└─────────────────┘The Implementation#
Laravel: API Setup
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
// Products
Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{slug}', [ProductController::class, 'show']);
// Orders
Route::post('/orders', [OrderController::class, 'store']);
Route::get('/orders', [OrderController::class, 'index']);
// User
Route::get('/user', fn() => auth()->user());
});
// Public routes
Route::post('/auth/login', [AuthController::class, 'login']);
Route::post('/auth/register', [AuthController::class, 'register']);Key: Use Laravel Sanctum for API authentication. It's simple, stateless, and works perfectly with Next.js.
Next.js: API Client
// lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL;
class ApiClient {
private getHeaders() {
const token = localStorage.getItem('auth_token');
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
};
}
async get<T>(endpoint: string): Promise<T> {
const res = await fetch(`${API_URL}${endpoint}`, {
headers: this.getHeaders(),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
async post<T>(endpoint: string, data: any): Promise<T> {
const res = await fetch(`${API_URL}${endpoint}`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
}
export const api = new ApiClient();
// Usage in Server Components
export async function getProducts() {
return api.get<Product[]>('/api/products');
}
// Usage in Client Components
export function useProducts() {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
api.get<Product[]>('/api/products')
.then(setProducts)
.catch(console.error);
}, []);
return products;
}The Real Power: Background Jobs#
This is where Laravel shines. Order placed? Queue everything heavy:
// OrderController.php
public function store(Request $request)
{
$order = DB::transaction(function () use ($request) {
// Create order
$order = Order::create([
'user_id' => auth()->id(),
'total' => $request->total,
'status' => 'pending',
]);
// Add items
foreach ($request->items as $item) {
$order->items()->create($item);
}
return $order;
});
// Queue all the heavy stuff
ProcessOrderPayment::dispatch($order)->onQueue('payments');
SendOrderConfirmation::dispatch($order)->onQueue('emails');
SyncToInventorySystem::dispatch($order)->onQueue('integrations');
GenerateInvoicePDF::dispatch($order)->onQueue('pdfs');
// Instant response to Next.js
return response()->json([
'order_id' => $order->id,
'status' => 'processing',
], 201);
}Next.js gets instant response. Background workers handle the rest. Beautiful.
Real-Time Updates: WebSockets#
Laravel Broadcasting + Pusher/Soketi for WebSockets:
// Events/OrderStatusUpdated.php
class OrderStatusUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('user.' . $this->order->user_id),
];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'status' => $this->order->status,
'message' => "Your order is {$this->order->status}",
];
}
}
// After payment processes
broadcast(new OrderStatusUpdated($order));Next.js listens:
// components/OrderTracker.tsx
'use client';
import { useEffect, useState } from 'react';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
export function OrderTracker({ orderId, userId }: Props) {
const [status, setStatus] = useState('processing');
useEffect(() => {
const echo = new Echo({
broadcaster: 'pusher',
key: process.env.NEXT_PUBLIC_PUSHER_KEY,
cluster: 'ap2',
forceTLS: true,
authEndpoint: `${process.env.NEXT_PUBLIC_API_URL}/broadcasting/auth`,
});
echo.private(`user.${userId}`)
.listen('OrderStatusUpdated', (data) => {
if (data.order_id === orderId) {
setStatus(data.status);
}
});
return () => echo.disconnect();
}, [orderId, userId]);
return (
<div className="status-tracker">
<h3>Order Status: {status}</h3>
{/* Fancy UI here */}
</div>
);
}Deployment Strategy#
Keep them separate:
| Service | Platform | Why |
|---|---|---|
| Next.js Frontend | Vercel | Zero-config, edge functions, automatic HTTPS |
| Laravel API | AWS EC2 + RDS | Full control, background workers, WebSockets |
| Redis/Queue | AWS ElastiCache | Managed, fast, reliable |
| File Storage | AWS S3 | Cheap, CDN-ready |
Environment Variables
# Next.js (.env.local)
NEXT_PUBLIC_API_URL=https://api.yourapp.com
NEXT_PUBLIC_PUSHER_KEY=your-pusher-key
NEXT_PUBLIC_PUSHER_CLUSTER=ap2
# Laravel (.env)
APP_URL=https://api.yourapp.com
FRONTEND_URL=https://yourapp.com
SANCTUM_STATEFUL_DOMAINS=yourapp.com
SESSION_DOMAIN=.yourapp.comCORS Configuration#
Critical for API communication:
// config/cors.php
return [
'paths' => ['api/*', 'broadcasting/auth'],
'allowed_methods' => ['*'],
'allowed_origins' => [
env('FRONTEND_URL'),
'http://localhost:3000', // Local dev
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];Performance Numbers#
Toytopia (e-commerce with 1000+ products):
| Metric | Value |
|---|---|
| Homepage Load (Next.js SSR) | 1.2s |
| API Response Time (p95) | 120ms |
| Background Job Processing | < 5s |
| WebSocket Latency | < 100ms |
| Lighthouse Score | 95+ |
| Daily Active Users | 2,000+ |
| Concurrent Orders (Black Friday) | 47 |
What This Stack Gives You#
When NOT to Use This#
Don't overcomplicate:
❌ Simple landing page → Use Next.js only
❌ Internal dashboard → Laravel + Inertia is enough
❌ No background jobs → Full-stack Next.js works
❌ Tiny team → Maintain one codebase, not two
Key Takeaways#
1. Use Next.js for what it's great at - SEO, UI, React ecosystem
2. Use Laravel for what it's great at - Background jobs, payments, complex logic
3. Laravel Sanctum for auth - Simple, stateless, perfect for APIs
4. Queue everything heavy - Keep API responses instant
5. WebSockets for real-time - Laravel Broadcasting is unmatched
This stack scales. It ships fast. It's maintainable. And most importantly - it doesn't fight you at 3 AM when production breaks.
Tags
Mohammad Kanaan
Senior Full-Stack Engineer specializing in Next.js, React, Laravel, and scalable web architectures. Building production-grade applications with modern tech stacks.
Enjoyed this article?
Get notified when I publish new content about web development, architecture patterns, and tech insights.
No spam, ever. Unsubscribe at any time.