Back to Blog
Architecture

Next.js + Laravel: The Stack That Actually Scales (And Why I'll Never Go Back)

December 9, 20249 min readBy Mohammad Kanaan
Share:

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

Tried to build a queue worker in Next.js. Ended up with a cron job calling an API route. That's not a queue. That's a hack.

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

PHP
// 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

TYPESCRIPT
// 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:

PHP
// 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:

PHP
// 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:

TYPESCRIPT
// 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:

ServicePlatformWhy
Next.js FrontendVercelZero-config, edge functions, automatic HTTPS
Laravel APIAWS EC2 + RDSFull control, background workers, WebSockets
Redis/QueueAWS ElastiCacheManaged, fast, reliable
File StorageAWS S3Cheap, CDN-ready

Environment Variables

BASH
# 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.com

CORS Configuration
#

Critical for API communication:

PHP
// 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):

MetricValue
Homepage Load (Next.js SSR)1.2s
API Response Time (p95)120ms
Background Job Processing< 5s
WebSocket Latency< 100ms
Lighthouse Score95+
Daily Active Users2,000+
Concurrent Orders (Black Friday)47

What This Stack Gives You
#

✅ Next.js: Best-in-class SEO, React ecosystem, Vercel deployment ✅ Laravel: Mature backend, queue workers, admin panels, payment SDKs ✅ Separation: Scale frontend and backend independently ✅ Speed: Ship features fast with both ecosystems

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

#Next.js#Laravel#Architecture#Scalability#Full-Stack
MK

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.