CMS + Next.js: Server-Side Rendering
Render CMS content on the server so the initial HTML contains the page's content. This example uses the server helper from Setup and the post operations in Queries.
Fetch published posts
Create a reusable server module. The publication filter is fixed in the document, so callers cannot replace it with draft.
// lib/erxes/posts.ts
import "server-only";
import { cache } from "react";
import { erxesQuery } from "./server";
export type PostSummary = {
_id: string;
title: string | null;
slug: string | null;
excerpt: string | null;
status: string | null;
};
type PostListData = {
cpPostList: {
posts: Array<PostSummary | null> | null;
} | null;
};
const POST_LIST_QUERY = `
query WebsitePublishedPosts($searchValue: String) {
cpPostList(status: published, limit: 12, searchValue: $searchValue) {
posts {
_id
title
slug
excerpt
status
}
}
}
`;
export async function getPublishedPosts(searchValue = "") {
const data = await erxesQuery<PostListData>(POST_LIST_QUERY, { searchValue });
return (data.cpPostList?.posts ?? []).filter(
(post): post is PostSummary =>
post !== null && post.status === "published" && Boolean(post.slug),
);
}
const POST_QUERY = `
query WebsitePost($slug: String!) {
cpPost(slug: $slug) {
_id
title
slug
excerpt
status
}
}
`;
export const getPublishedPost = cache(async (slug: string) => {
const data = await erxesQuery<{ cpPost: PostSummary | null }>(
POST_QUERY,
{ slug },
);
const post = data.cpPost;
return post?.status === "published" ? post : null;
});
The post query builder only adds a status filter when one is supplied. The detail resolver does not itself restrict the result to published posts. Check the status before returning a detail record or using its fields in metadata.
Render the index
// app/blog/page.tsx
import { getPublishedPosts } from "@/lib/erxes/posts";
export default async function BlogPage() {
const posts = await getPublishedPosts();
return (
<main>
<h1>Blog</h1>
{posts.length === 0 ? <p>No published posts yet.</p> : (
<ul>
{posts.map(post => (
<li key={post._id}>
<a href={`/blog/${encodeURIComponent(post.slug!)}`}>
{post.title || "Untitled post"}
</a>
{post.excerpt ? <p>{post.excerpt}</p> : null}
</li>
))}
</ul>
)}
</main>
);
}
This example displays the first 12 matching posts. For a complete archive, add cursor pagination using the pageInfo fields documented in Queries.
Render a detail route
This route-prop type follows the asynchronous params API in Next.js 15 and later, including the product's Next.js 16 portal and help-center apps. If your separate website uses Next.js 14, use params: { slug: string } instead; the await params line can remain.
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPublishedPost } from "@/lib/erxes/posts";
type Props = {
params: Promise<{ slug: string }>;
};
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPublishedPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title || "Untitled post"}</h1>
{post.excerpt ? <p>{post.excerpt}</p> : null}
</article>
);
}
This minimal example displays plain text. To render the full content field, determine its stored format and use an appropriate renderer. If it contains HTML, sanitize it with a maintained server-side HTML sanitizer before inserting it as markup. Do not insert untrusted content directly with dangerouslySetInnerHTML.
For SEO metadata, call the same getPublishedPost helper from generateMetadata; return missing-page metadata when it returns null.
Caching and view counts
The Setup helper uses cache: "no-store". Adding export const revalidate = 300 to a page does not turn those uncached requests into five-minute cached fetches. Choose caching at the data layer when the content and authorization model allow it. See Next.js 14 data fetching and caching.
React's cache wrapper deduplicates the detail helper within a server rendering request; it is not persistent storage across requests. cpPost increments the post view count, so repeated reads can increase that count while caching can reduce counted reads. It is not a reliable count of unique human visitors.
For draft previews or user-specific content, keep caching and authorization separate from the public published-content path.
Loading and errors
Add an app/blog/loading.tsx loading UI and an app/blog/error.tsx error boundary with a useful retry action. A missing or unpublished record should use the not-found path; a failed upstream request should remain an error.
Verify a published post, a draft, an unknown slug, an empty list, and an unavailable gateway. Continue with Client-side Rendering for interactive search.
Troubleshooting
data.cpPostListis null or an HTTP error: thex-app-tokenis missing or invalid. Confirm the token and gateway URL in the server environment.- Empty
postsarray: the portal token matched, but the portal has no published posts; check the records'clientPortalIdassociation andstatus. - Unknown argument errors: compare your document with the signatures in Queries; most lists use
limit/cursor, notpage/perPage.