I spend most of my working hours inside Laravel and Vue. That's my comfort zone — controllers, Eloquent, Blade when I'm lazy, Vue 3 when I'm not. So when I decided to rebuild my blog as a dynamic site with Next.js pulling content from a REST API, I expected a weekend project. It took longer than a weekend, and I learned a few things I wish someone had told me upfront.
This post walks through how I'd build a dynamic blog with Next.js and a REST API today — the actual structure, the code, and the parts of the official docs that quietly skip over real-world problems.
The setup: Next.js on the front, REST API on the back
The architecture is simple on paper. A backend exposes blog posts as JSON, and a Next.js app fetches and renders them. The backend can be anything — WordPress with its REST API, a headless CMS like Strapi, or in my case, a Laravel API, because that's what I already know how to build and host.
My Laravel endpoint looks like this:
// routes/api.php
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{slug}', [PostController::class, 'show']);
// app/Http/Controllers/PostController.php
public function index()
{
return PostResource::collection(
Post::published()->latest()->paginate(10)
);
}
public function show(string $slug)
{
return new PostResource(
Post::published()->where('slug', $slug)->firstOrFail()
);
}
Nothing clever. Slug-based URLs, pagination, a resource class to control the JSON shape. If you're using WordPress or a headless CMS instead, the Next.js side of this post stays exactly the same — only the URLs change.
Project structure with the App Router
Next.js 14 pushed everyone toward the App Router, and for a blog it's genuinely the right choice. Here's the minimal structure:
app/
├── page.tsx // homepage — post list
├── blog/
│ └── [slug]/
│ └── page.tsx // single post
├── layout.tsx
└── lib/
└── api.ts // fetch helpers
I keep all API calls in one file so I'm not scattering fetch calls with hardcoded URLs across components:
// app/lib/api.ts
const API_URL = process.env.API_URL; // e.g. https://api.myblog.com/api
export async function getPosts() {
const res = await fetch(`${API_URL}/posts`, {
next: { revalidate: 300 }, // re-fetch at most every 5 minutes
});
if (!res.ok) throw new Error('Failed to fetch posts');
const json = await res.json();
return json.data;
}
export async function getPost(slug: string) {
const res = await fetch(`${API_URL}/posts/${slug}`, {
next: { revalidate: 300 },
});
if (!res.ok) return null;
const json = await res.json();
return json.data;
}
That next: { revalidate: 300 } line is doing a lot of work, and I'll come back to it — it's the part that confused me the most coming from the Laravel world.
Rendering the post list
Server components make the listing page almost boring:
// app/page.tsx
import Link from 'next/link';
import { getPosts } from './lib/api';
export default async function HomePage() {
const posts = await getPosts();
return (
<main>
<h1>Latest Posts</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
<p>{post.excerpt}</p>
</article>
))}
</main>
);
}
Notice there's no useEffect, no loading state, no client-side fetching library. The component is async, it awaits the data on the server, and the user receives finished HTML. Coming from Vue SPAs where I'd wire up a Pinia store and a loading spinner for this exact page, this felt almost suspiciously easy.
The single post page
The dynamic route handles individual posts:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPost } from '../../lib/api';
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<time>{new Date(post.published_at).toDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: post.content_html }} />
</article>
);
}
Two practical notes here. First, notFound() gives you a proper 404 instead of a crashed page when someone hits a bad slug. Second, I return sanitized HTML from the API (content_html) rather than raw Markdown, because sanitizing on the backend once is safer than trusting every frontend consumer to do it. If your API returns Markdown, render it with something like react-markdown instead of dangerouslySetInnerHTML.
For SEO, add generateMetadata in the same file:
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
};
}
Next.js deduplicates the two getPost calls automatically, so this doesn't hit your API twice. That deduplication is one of those small things the framework does silently and you only appreciate when you check your API logs.
The caching gotcha that cost me an evening
Here's the part that actually tripped me up. In Next.js 14, fetch responses in server components are cached aggressively by default. I published a new post, refreshed my homepage, and... nothing. Refreshed again. Nothing. I assumed my Laravel cache was the problem and spent a good hour clearing things on the wrong end.
The fix is that revalidate option you saw earlier. It tells Next.js the cached response is valid for N seconds, after which the next request triggers a background re-fetch. For a blog, revalidate: 300 is a sensible default — new posts appear within five minutes without you redeploying anything.
If five minutes isn't fresh enough, on-demand revalidation is the cleaner answer: expose a route handler in Next.js that calls revalidatePath('/'), and have your backend ping it whenever a post is published. In Laravel that's a one-line HTTP call in a model observer. It's a nice pattern — the CMS pushes freshness instead of the frontend polling for it.
What I'd actually do
Honest opinion time, as someone whose day job is Laravel and Vue, not React.
If you already have a backend you're comfortable with, keep it. There's a lot of content online implying that going with Next.js means going all-in on the JavaScript ecosystem — API routes, Prisma, the whole thing. You don't have to. My Laravel API sits on cheap shared hosting with cPanel, exactly where my other projects live, and the Next.js frontend deploys to Vercel's free tier. The two don't care about each other beyond a JSON contract, and that separation has been nothing but pleasant.
Second: for a blog specifically, resist the urge to make things client-side. My Vue instincts kept whispering "fetch this in the component, add a store, add a skeleton loader." Server components made all of that unnecessary, and the site is faster for it. The right amount of client-side JavaScript in a blog is close to zero.
Third, the thing nobody says plainly: the hard part isn't Next.js or the API. It's the boring glue — CORS headers on the backend, environment variables that differ between local and Vercel, and understanding the caching model. Budget your frustration for those, not for React itself.
Would I use this stack again? For a content site with an existing backend — yes, without hesitation. If I were starting with zero backend and just wanted to write posts, I'd honestly consider skipping the API entirely and using Markdown files in the repo. Dynamic is only worth it when the content actually changes often or comes from somewhere else.
Final thoughts
A dynamic blog with Next.js and a REST API comes down to three pieces: an API that serves clean JSON, server components that fetch and render it, and a caching strategy you've consciously chosen instead of stumbled into. The code is short. The concepts — especially around caching and revalidation — are where the real learning is.
If you're coming from the Laravel or Vue world like me, the good news is that your backend skills transfer completely, and the frontend half is smaller than it looks. Build the API the way you always have, let Next.js do the rendering, and spend the time you saved writing actual posts.
Admin User
TechHub Administrator
Passionate about building great software and sharing knowledge with the developer community.
