Next.js Integration Guide
UnfoldCMS works as a headless CMS for Next.js. You don't need an npm package — just fetch(). For dynamic content updates, register a webhook that triggers ISR revalidation.
Setup
Set the CMS base URL in your Next.js env:
# .env.local
NEXT_PUBLIC_CMS_URL=https://your-cms.com
UNFOLDCMS_WEBHOOK_SECRET=your-webhook-secret-here
That's it. No SDK install, no provider component.
Reading Posts (Server Components)
// app/blog/page.tsx
export const revalidate = 3600; // ISR every hour, or use webhook revalidation
async function getPosts() {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/posts?per_page=20`, {
next: { revalidate: 3600 },
});
if (!res.ok) throw new Error('Failed to load posts');
return res.json();
}
export default async function BlogIndex() {
const { data: posts } = await getPosts();
return (
<div>
<h1>Blog</h1>
<ul>
{posts.map((post: any) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</a>
</li>
))}
</ul>
</div>
);
}
Single Post Page
// app/blog/[slug]/page.tsx
type Params = { params: { slug: string } };
export async function generateStaticParams() {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/posts?per_page=100`);
const { data: posts } = await res.json();
return posts.map((p: any) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: Params) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/posts/${params.slug}`,
{ next: { revalidate: 3600 } }
);
if (!res.ok) return { title: 'Not Found' };
const { data: post } = await res.json();
return {
title: post.seo_title || post.title,
description: post.meta_desc || post.excerpt,
};
}
export default async function PostPage({ params }: Params) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/posts/${params.slug}`,
{ next: { revalidate: 3600 } }
);
if (!res.ok) return <div>Post not found</div>;
const { data: post } = await res.json();
return (
<article>
<h1>{post.title}</h1>
<p>by {post.author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}
Webhook-Triggered ISR
Polling every hour is fine for blogs. For instant updates when a post publishes, register a webhook in UnfoldCMS pointing at a revalidation endpoint:
Revalidation Endpoint
// app/api/revalidate/route.ts (App Router)
import { revalidatePath } from 'next/cache';
import crypto from 'crypto';
export async function POST(req: Request) {
const signature = req.headers.get('signature');
const raw = await req.text(); // raw body for HMAC
const expected = crypto
.createHmac('sha256', process.env.UNFOLDCMS_WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
if (
!signature ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const { event, data } = JSON.parse(raw);
if (event === 'post.published' || event === 'post.updated') {
revalidatePath(`/blog/${data.slug}`);
revalidatePath('/blog');
}
return Response.json({ revalidated: true });
}
Register the Webhook
curl -X POST https://your-cms.com/api/v1/admin/webhooks \
-H "Authorization: Bearer ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Next.js Production",
"url": "https://your-nextjs-app.com/api/revalidate",
"events": ["post.published", "post.updated", "post.deleted"]
}'
Save the secret from the response into UNFOLDCMS_WEBHOOK_SECRET. Done.
Now whenever someone publishes a post in UnfoldCMS, your Next.js app revalidates the matching page within seconds.
Categories and Tags
async function getCategories() {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/categories`);
return res.json();
}
async function getPostsInCategory(slug: string) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/categories/${slug}/posts`
);
return res.json();
}
Search
// app/search/page.tsx
export default async function SearchPage({ searchParams }: { searchParams: { q?: string } }) {
if (!searchParams.q || searchParams.q.length < 2) {
return <div>Search needs at least 2 characters</div>;
}
const res = await fetch(
`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/search?q=${encodeURIComponent(searchParams.q)}`,
{ next: { revalidate: 60 } }
);
const { data } = await res.json();
return (
<div>
<h1>Results for "{searchParams.q}"</h1>
<p>{data.total} matches</p>
{data.posts.map((p: any) => (
<a key={p.id} href={`/blog/${p.slug}`}>{p.title}</a>
))}
</div>
);
}
Site Settings (Logo, Social Links, etc.)
// app/layout.tsx
async function getSiteSettings() {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/settings/public`, {
next: { revalidate: 3600 },
});
return res.json();
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { data: settings } = await getSiteSettings();
return (
<html>
<body>
<header>
<img src={settings['general.logo_url']} alt={settings['general.site_name']} />
<h1>{settings['general.site_name']}</h1>
</header>
{children}
</body>
</html>
);
}
Navigation Menus
async function getMenu(location: string) {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/v1/menus/${location}`);
if (!res.ok) return null;
return res.json();
}
function MenuTree({ items }: { items: any[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>
<a href={item.url} target={item.target}>{item.label}</a>
{item.children.length > 0 && <MenuTree items={item.children} />}
</li>
))}
</ul>
);
}
export default async function Header() {
const menu = await getMenu('header');
return <nav>{menu && <MenuTree items={menu.data.items} />}</nav>;
}
Common Pitfalls
next: { revalidate: 0 }defeats caching. Use60or higher for blog content. Use webhook revalidation for instant updates.- Set the
Accept: application/jsonheader on auth endpoints. Without it, Laravel may return HTML redirects on 401 instead of JSON. - Don't put admin tokens in client components. Admin tokens go in env vars and are used in Server Components / Route Handlers only.
- Images are absolute URLs in the API response. Don't prepend the CMS URL —
featured_image.largeis already a complete URL.