Next.js · App Router · 2026

CMS for Next.js

ElmapiCMS is a self-hosted headless CMS with a REST API, multi-project support, and a one-time license. This guide shows you how to connect it to a Next.js App Router project: install the SDK, fetch content in server components, set up dynamic routes, and keep pages fresh with ISR and webhooks.

Why

A good fit for Next.js projects

ElmapiCMS handles the content layer (collections, locales, assets, webhooks) while your Next.js app renders it however you want. Editors work in the CMS dashboard. Developers work in code. Changes on either side don't block the other.

One ElmapiCMS install can serve multiple Next.js projects through separate API tokens and project IDs. That means less infrastructure if you're building sites for clients or running multiple products.

If you prefer a hands-on walkthrough, start with How to Build a Simple Blog Using ElmapiCMS and Next.js or the starter template setup guide. This page covers the patterns you'll need once you're past the basics.

Setup

Install the SDK and configure env variables

Add the official JavaScript SDK to your Next.js project:

npm install @elmapicms/js-sdk

Create a .env.local file with your CMS credentials. These are server-only variables. Do not prefix them with NEXT_PUBLIC_ because that would expose your API key to browsers.

ELMAPI_PROJECT_ID=your-project-uuid
ELMAPI_API_KEY=your-token
ELMAPI_API_URL=https://your-cms.example.com/api

You'll find the Project ID in your CMS project settings, and you can create API tokens under Settings → API Access. For a public website, a read-only token is enough.

Client

Create a shared CMS client

Wrap the SDK in a helper function so every server component and route handler uses the same configuration. Keep this file server-only.

// lib/elmapi.ts
import { createClient } from "@elmapicms/js-sdk";
 
export function cms() {
  const url = process.env.ELMAPI_API_URL;
  const key = process.env.ELMAPI_API_KEY;
  const projectId = process.env.ELMAPI_PROJECT_ID;
 
  if (!url || !key || !projectId) {
    throw new Error("Missing ElmapiCMS environment variables");
  }
 
  return createClient(url, key, projectId);
}

The createClient function takes your API URL, token, and project ID. It returns a client with methods like getEntries, getEntry, getCollections, and getAssets. Check the API docs for the full reference.

Pages

Fetch content in server components

In the App Router, async server components are the default. Call the CMS directly at the top level of your page. No useEffect, no loading states, no client-side fetching needed.

List page

Fetch all entries from a collection and render them. The revalidate export tells Next.js to refresh this page every 60 seconds.

// app/posts/page.tsx
import { cms } from "@/lib/elmapi";
 
export const revalidate = 60;
 
export default async function PostsPage() {
  const result = await cms().getEntries("posts", {
    sort: "created_at:desc",
    paginate: 12,
    page: 1,
    state: "published",
  });
 
  const posts = Array.isArray(result) ? result : result.data;
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.uuid}>
          <a href={`/posts/${post.fields.slug}`}>
            {post.fields.title}
          </a>
        </li>
      ))}
    </ul>
  );
}

When you use paginate, the response wraps entries in { data, meta, links }. Without pagination you get a plain array. The Array.isArray check handles both shapes.

Detail page with static generation

Use generateStaticParams to pre-render post pages at build time, and a where clause to fetch the matching entry by slug.

// app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
import { cms } from "@/lib/elmapi";
 
export const revalidate = 60;
 
type Props = { params: Promise<{ slug: string }> };
 
export async function generateStaticParams() {
  const result = await cms().getEntries("posts", {
    state: "published",
    paginate: 100,
    page: 1,
  });
  const posts = Array.isArray(result) ? result : result.data;
 
  return posts
    .filter((p) => p.fields?.slug)
    .map((p) => ({ slug: p.fields.slug }));
}
 
export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const result = await cms().getEntries("posts", {
    where: { slug },
    limit: 1,
    state: "published",
  });
 
  const rows = Array.isArray(result) ? result : result.data;
  const post = rows[0];
  if (!post) notFound();
 
  return (
    <article>
      <h1>{post.fields.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.fields.body }} />
    </article>
  );
}
Queries

Filtering, sorting, and pagination

The SDK supports where clauses for filtering, sort for ordering (use field:asc or field:desc), and paginate + page for pagination. You can also fetch a single entry by UUID with getEntry.

// Get entries with a where clause
const featured = await cms().getEntries("posts", {
  where: { featured: true },
  sort: "created_at:desc",
  state: "published",
});
 
// Operator-based filtering
const recent = await cms().getEntries("posts", {
  where: { created_at: { gte: "2026-01-01" } },
  sort: "created_at:desc",
  paginate: 10,
  page: 1,
});
 
// Get a single entry by UUID
const entry = await cms().getEntry("posts", "entry-uuid-here");

For advanced query patterns like nested operators and multi-field filtering, see the advanced filtering docs and filtering examples.

Caching

ISR, revalidation, and webhooks

The ElmapiCMS SDK uses its own HTTP client, not the built-in fetch, so Next.js Data Cache tags don't apply automatically. Here are three practical approaches to keep content fresh:

  • Time-based (simplest): Add export const revalidate = 60 to your page. Next.js will re-render the page at most every 60 seconds.
  • On-demand with webhooks: Create a Route Handler that calls revalidatePath when your CMS sends a webhook. This gives you instant updates when content changes.
  • Tag-based: Wrap SDK calls in unstable_cache with cache tags, then call revalidateTag from your webhook handler for granular invalidation.

For most projects, start with time-based revalidation and add webhooks when you need instant updates.

Webhook handler example

Set up a webhook in ElmapiCMS that fires on content changes. Point it at a Route Handler that verifies the webhook signature and revalidates the right paths.

// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { createHmac, timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
 
export async function POST(request: NextRequest) {
  const rawBody = await request.text();
  const secret = process.env.ELMAPI_REVALIDATE_SECRET;
  const signature = request.headers.get("x-webhook-signature");
 
  if (secret) {
    if (!signature) {
      return NextResponse.json({ error: "Missing signature" }, { status: 401 });
    }
 
    const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
    const isValid =
      signature.length === expected.length &&
      timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
 
    if (!isValid) {
      return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
    }
  }
 
  const body = rawBody ? JSON.parse(rawBody) : {};
  const slug = body?.content_entry?.fields?.slug;
 
  if (slug) {
    revalidatePath(`/posts/${slug}`);
  }
 
  revalidatePath("/posts");
  return NextResponse.json({ revalidated: true });
}

Add ELMAPI_REVALIDATE_SECRET to your .env.local and use the same value as your webhook secret in ElmapiCMS.

AI

Use with Cursor, Claude Code, and MCP

If you use an AI code editor, the official @elmapicms/mcp-server package lets your editor read and write CMS content directly. It works with any MCP-compatible client including Cursor and Claude Code.

This means your AI assistant can create collections, add entries, upload assets, and query content without you leaving the editor. Read more in Introducing the ElmapiCMS MCP Server.

Checklist

Before you deploy

  • API keys are in server-only env vars (no NEXT_PUBLIC_ prefix).
  • Public routes only fetch state: "published" content.
  • Detail pages return notFound() when a slug doesn't match, so search engines get a proper 404.
  • You have a revalidation strategy: time-based, webhook-driven, or both.
  • List responses handle both paginated (result.data) and non-paginated (result) shapes.
Resources

Keep going

Last updated: June 27, 2026