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.
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.
Install the SDK and configure env variables
Add the official JavaScript SDK to your Next.js project:
npm install @elmapicms/js-sdkCreate 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/apiYou'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.
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.
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>
);
}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.
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 = 60to your page. Next.js will re-render the page at most every 60 seconds. - On-demand with webhooks: Create a Route Handler that calls
revalidatePathwhen your CMS sends a webhook. This gives you instant updates when content changes. - Tag-based: Wrap SDK calls in
unstable_cachewith cache tags, then callrevalidateTagfrom 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.
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.
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.
Keep going
- Blog tutorial for a step-by-step walkthrough from zero
- Starter template guide to get a working project in minutes
- ISR + webhooks deep dive for production caching patterns
- App Router caching for CMS editors
- Full documentation