← Volver al blog

Next.js 16 con App Router: guía práctica para desarrolladores Laravel

2026-05-17

Introducción: Por qué un desarrollador Laravel debería mirar Next.js 16

Si venís construyendo aplicaciones con Laravel durante años, probablemente tengás un modelo mental claro: rutas que apuntan a controladores, vistas Blade con directivas, y una arquitectura MVC que escala predeciblemente. Next.js 16 con App Router representa un cambio de paradigma en el frontend, pero no tan drástico como parece. De hecho, muchos conceptos que ya dominás tienen correspondencias directas en el ecosistema React.

En esta guía práctica vamos a mapear tu conocimiento de Laravel a Next.js 16, enfocándonos en el App Router introducido en Next.js 13 y consolidado en la versión 16. El objetivo no es reemplazar tu stack, sino darte las herramientas para integrar un frontend moderno cuando el proyecto lo requiera.

Del enrutamiento de Laravel al App Router de Next.js

En Laravel definís rutas en archivos como routes/web.php:

// Laravel
Route::get('/usuarios/{id}', [UserController::class, 'show']);
Route::get('/dashboard', [DashboardController::class, 'index'])->middleware('auth');

En Next.js 16 con App Router, el enrutamiento es basado en convención de archivos. Cada directorio dentro de app/ representa un segmento de URL:

app/
├── page.tsx              // Ruta: /
├── layout.tsx            // Layout raíz
├── loading.tsx           // UI de carga
├── error.tsx             // UI de errores
├── dashboard/
│   ├── page.tsx          // Ruta: /dashboard
│   ├── layout.tsx        // Layout específico de dashboard
│   └── settings/
│       └── page.tsx      // Ruta: /dashboard/settings
└── usuarios/
    └── [id]/
        └── page.tsx      // Ruta: /usuarios/:id

Observá que page.tsx equivale a un método de controlador que retorna una vista. Los parámetros dinámicos usan corchetes, similar a las rutas de Laravel pero declarativo.

Layout vs @extends('layouts.app')

En Laravel compartís estructura con Blade:

<!-- layouts/app.blade.php -->
<html>
  <body>
    <nav>...</nav>
    <main>@yield('content')</main>
  </body>
</html>

En Next.js 16, los layouts anidan automáticamente:

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="es">
      <body>
        <nav>Navegación global</nav>
        <main>{children}</main>
      </body>
    </html>
  )
}

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="dashboard-container">
      <aside>Menú lateral</aside>
      <section>{children}</section>
    </div>
  )
}

El layout de dashboard se anida dentro del layout raíz. No necesitás indicar explícitamente la herencia; Next.js resuelve la estructura automáticamente según la ruta solicitada.

Fetching de datos: De Eloquent a Server Components

En Laravel, tu controlador consulta la base de datos y pasa datos a la vista:

public function show(int $id): View
{
    $user = User::with('posts')->findOrFail($id);
    return view('users.show', compact('user'));
}

Next.js 16 introduce Server Components por defecto en el App Router. Esto significa que puedes hacer fetching de datos directamente en el componente, sin useEffect ni estados de carga manuales:

// app/usuarios/[id]/page.tsx
import { notFound } from 'next/navigation'

interface User {
  id: number
  name: string
  email: string
  posts: { id: number; title: string }[]
}

async function getUser(id: string): Promise<User> {
  const res = await fetch(`https://api.tudominio.com/users/${id}`, {
    // Revalidación similar a cache de Laravel
    next: { revalidate: 3600 }
  })
  
  if (!res.ok) {
    if (res.status === 404) notFound()
    throw new Error('Fallo al obtener usuario')
  }
  
  return res.json()
}

export default async function UserPage({
  params,
}: {
  params: { id: string }
}) {
  const user = await getUser(params.id)
  
  return (
    <article>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      
      <h2>Publicaciones</h2>
      <ul>
        {user.posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </article>
  )
}

Clave para desarrolladores Laravel: el componente se ejecuta en el servidor, similar a cómo Blade se renderiza server-side. La diferencia es que el resultado es HTML interactivo cuando corresponde, no un string estático.

Manejo de estados de carga y errores

En Laravel manejás esto con condicionales en Blade o middleware. En Next.js 16, los archivos loading.tsx y error.tsx parallel routes manejan estos estados declarativamente:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="skeleton-loader">
      <div className="skeleton-header" />
      <div className="skeleton-content" />
    </div>
  )
}

// app/dashboard/error.tsx
'use client'

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="error-container">
      <h2>Algo salió mal</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Reintentar</button>
    </div>
  )
}

Mutaciones de datos: Del form request a Server Actions

En Laravel validás y procesás datos con Form Requests:

public function store(StoreUserRequest $request): RedirectResponse
{
    $user = User::create($request->validated());
    return redirect()->route('users.show', $user)->with('success', 'Usuario creado');
}

Next.js 16 establece Server Actions como patrón estándar para mutaciones. Son funciones asíncronas que ejecutan en el servidor, invocables directamente desde componentes:

// app/usuarios/nuevo/page.tsx
import { revalidatePath } from 'next/cache'

interface FormData {
  name: string
  email: string
}

async function createUser(formData: FormData) {
  'use server'
  
  // Validación equivalente a Form Request
  if (!formData.name || formData.name.length < 3) {
    throw new Error('El nombre debe tener al menos 3 caracteres')
  }
  
  const res = await fetch('https://api.tudominio.com/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(formData),
  })
  
  if (!res.ok) {
    throw new Error('Error al crear usuario')
  }
  
  revalidatePath('/usuarios')
  return res.json()
}

export default function NewUserPage() {
  return (
    <form action={async (formData) => {
      'use server'
      await createUser({
        name: formData.get('name') as string,
        email: formData.get('email') as string,
      })
    }}>
      <input name="name" placeholder="Nombre" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit">Crear Usuario</button>
    </form>
  )
}

Notá que no necesitás manejar preventDefault, estados de carga, ni errores manualmente. Next.js 16 optimiza automáticamente la experiencia con progressive enhancement: el form funciona sin JavaScript, y se enriquece cuando está disponible.

Integración con APIs Laravel existentes

Probablemente no vas a migrar tu backend de un día para otro. Next.js 16 se integra elegantemente con APIs Laravel existentes mediante patrones de autenticación por tokens.

// lib/auth.ts
import { cookies } from 'next/headers'

export async function getLaravelToken(): Promise<string | null> {
  const cookieStore = await cookies()
  return cookieStore.get('laravel_token')?.value ?? null
}

// lib/api.ts
const API_URL = process.env.LARAVEL_API_URL

export async function laravelFetch(
  endpoint: string,
  options: RequestInit = {}
) {
  const token = await getLaravelToken()
  
  const headers = new Headers(options.headers)
  headers.set('Accept', 'application/json')
  
  if (token) {
    headers.set('Authorization', `Bearer ${token}`)
  }
  
  const response = await fetch(`${API_URL}${endpoint}`, {
    ...options,
    headers,
  })
  
  if (response.status === 401) {
    // Redirigir a login de Laravel o manejar refresh token
    throw new AuthError('Sesión expirada')
  }
  
  return response
}

class AuthError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'AuthError'
  }
}

Para autenticación stateless entre Laravel y Next.js, considerá usar Laravel Sanctum con tokens de corta duración y refresh tokens rotativos. El Server Component de Next.js puede verificar la sesión en cada request sin exponer lógica al cliente.

Client Components: Cuándo y cómo usarlos

En Laravel, todo el HTML se genera en el servidor. En Next.js 16, la regla es: usa Server Components por defecto, y solo optá por Client Components cuando necesites interactividad.

Indicás un Client Component con la directiva 'use client':

'use client'

import { useState } from 'react'

export default function UserFilter({ users }: { users: User[] }) {
  const [filter, setFilter] = useState('')
  
  const filtered = users.filter(u => 
    u.name.toLowerCase().includes(filter.toLowerCase())
  )
  
  return (
    <div>
      <input
        value={filter}
        onChange={e => setFilter(e.target.value)}
        placeholder="Filtrar usuarios..."
      />
      <ul>
        {filtered.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

Un patrún útil para desarrolladores Laravel es mantener la "página" como Server Component y extraer la interactividad a "componentes cliente":

// Server Component: app/usuarios/page.tsx
import { laravelFetch } from '@/lib/api'
import UserFilter from './UserFilter' // Client Component

export default async function UsersPage() {
  const res = await laravelFetch('/users')
  const users = await res.json()
  
  return (
    <div>
      <h1>Usuarios</h1>
      <UserFilter users={users} />
    </div>
  )
}

Streaming y Suspense: El equivalente a jobs y queues

En Laravel, cuando una operación es lenta, la delegás a un job de queue y actualizás la UI vía polling o WebSockets. Next.js 16 ofrece streaming de componentes mediante Suspense:

import { Suspense } from 'react'
import { UserStats } from './UserStats' // Componente lento

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Renderiza inmediatamente */}
      <QuickStats />
      
      {/* Muestra fallback mientras carga */}
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
    </div>
  )
}

// UserStats.tsx
async function UserStats() {
  // Operación lenta: aggregación de datos
  const stats = await fetch('https://api.tudominio.com/stats', {
    next: { revalidate: 60 }
  }).then(r => r.json())
  
  return (
    <div>
      <p>Total usuarios: {stats.totalUsers}</p>
      <p>Usuarios activos: {stats.activeUsers}</p>
    </div>
  )
}

El HTML se envía al navegador progresivamente. El usuario ve QuickStats inmediatamente, mientras UserStats carga en paralelo. No hay bloqueo ni pantallas en blanco.

Configuración de proyecto: Un Dockerfile para ambos mundos

Para proyectos que combinan Laravel y Next.js, una estructura típica:

proyecto/
├── backend/               # Laravel
│   ├── Dockerfile
│   ├── app/
│   └── routes/api.php
├── frontend/              # Next.js 16
│   ├── Dockerfile
│   ├── app/
│   └── package.json
└── docker-compose.yml

El Dockerfile de Next.js 16 aprovecha la build estática cuando no necesitás Node en runtime:

# frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

COPY . .
RUN npm run build

# Producción con standalone output
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

EXPOSE 3000
CMD ["node", "server.js"]

Configurá output: 'standalone' en next.config.js para esta optimización.

Conclusión: El camino pragmático

Next.js 16 con App Router no reemplaza a Laravel; los complementa. Como desarrollador Laravel, ya tenés intuiciones sólidas sobre separación de responsabilidades, seguridad server-side, y arquitectura por capas. El App Router canaliza esas mismas intuiciones en el frontend.

Los puntos clave para recordar:

Empezá con un módulo pequeño —un dashboard de administración, un flujo de checkout— y expandí gradualmente. La curva de aprendizaje existe, pero es más transversal de lo que parece cuando venís del mundo backend-estructurado de Laravel.