Next.js 13 introduces the App Router with React Server Components for improved performance. Combined with Laravel APIs, it creates powerful full-stack architectures. At ZIRA Software, this stack powers our most demanding applications.
Project Structure
project/
├── frontend/ # Next.js 13 app
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── posts/
│ │ │ ├── page.tsx
│ │ │ └── [slug]/
│ │ │ └── page.tsx
│ │ └── api/
│ └── lib/
│ └── api.ts
└── backend/ # Laravel API
├── app/
├── routes/api.php
└── ...
Laravel API Setup
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('posts', PostController::class);
Route::apiResource('users', UserController::class);
});
// app/Http/Controllers/Api/PostController.php
class PostController extends Controller
{
public function index()
{
$posts = Post::with('author:id,name')
->published()
->latest()
->paginate(10);
return PostResource::collection($posts);
}
public function show(Post $post)
{
$post->load(['author', 'comments.user']);
return new PostResource($post);
}
}
// CORS configuration for Next.js
// config/cors.php
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
Next.js API Client
// lib/api.ts
const API_URL = process.env.LARAVEL_API_URL || 'http://localhost:8000/api/v1';
export async function fetchPosts(page = 1) {
const res = await fetch(`${API_URL}/posts?page=${page}`, {
next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export async function fetchPost(slug: string) {
const res = await fetch(`${API_URL}/posts/${slug}`, {
next: { revalidate: 60 },
});
if (!res.ok) throw new Error('Failed to fetch post');
return res.json();
}
// For mutations (client-side)
export async function createPost(data: PostData, token: string) {
const res = await fetch(`${API_URL}/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
}
Server Components for Data Fetching
// app/posts/page.tsx - Server Component
import { fetchPosts } from '@/lib/api';
import PostCard from '@/components/PostCard';
import Pagination from '@/components/Pagination';
interface Props {
searchParams: { page?: string };
}
export default async function PostsPage({ searchParams }: Props) {
const page = Number(searchParams.page) || 1;
const { data: posts, meta } = await fetchPosts(page);
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Blog Posts</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
<Pagination
currentPage={meta.current_page}
lastPage={meta.last_page}
/>
</div>
);
}
// Generate metadata
export async function generateMetadata({ searchParams }: Props) {
return {
title: `Blog Posts - Page ${searchParams.page || 1}`,
};
}
Dynamic Routes with generateStaticParams
// app/posts/[slug]/page.tsx
import { fetchPost, fetchPosts } from '@/lib/api';
import { notFound } from 'next/navigation';
interface Props {
params: { slug: string };
}
// Generate static paths at build time
export async function generateStaticParams() {
const { data: posts } = await fetchPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function PostPage({ params }: Props) {
try {
const { data: post } = await fetchPost(params.slug);
return (
<article className="container mx-auto py-8 max-w-3xl">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-gray-600 mb-8">
<span>By {post.author.name}</span>
<time>{new Date(post.published_at).toLocaleDateString()}</time>
</div>
<div
className="prose lg:prose-xl"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
} catch {
notFound();
}
}
export async function generateMetadata({ params }: Props) {
const { data: post } = await fetchPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
images: [post.featured_image],
},
};
}
Client Components for Interactivity
// components/CommentForm.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function CommentForm({ postId }: { postId: number }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ content }),
});
setContent('');
setLoading(false);
router.refresh(); // Refresh server component data
}
return (
<form onSubmit={handleSubmit} className="mt-8">
<textarea
value={content}
onChange={(e)=> setContent(e.target.value)}
className="w-full border rounded p-3"
rows={4}
placeholder="Write a comment..."
/>
<button
type="submit"
disabled={loading}
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded"
>
{loading ? 'Posting...' : 'Post Comment'}
</button>
</form>
);
}
Route Handlers for API Proxy
// app/api/posts/[id]/comments/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const cookieStore = cookies();
const token = cookieStore.get('auth_token')?.value;
const body = await request.json();
const res = await fetch(
`${process.env.LARAVEL_API_URL}/posts/${params.id}/comments`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
}
);
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
Conclusion
Next.js 13 App Router with Laravel APIs combines the best of React Server Components with robust backend capabilities. Server-side data fetching and client-side interactivity work seamlessly together.
Building full-stack applications? Contact ZIRA Software for Next.js and Laravel development.