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();
}
// 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>
  );
}
// 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>
  );
}
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. Use 60 or higher for blog content. Use webhook revalidation for instant updates.
  • Set the Accept: application/json header 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.large is already a complete URL.