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.cpPostList is null or an HTTP error: the x-app-token is missing or invalid. Confirm the token and gateway URL in the server environment.
  • Empty posts array: the portal token matched, but the portal has no published posts; check the records' clientPortalId association and status.
  • Unknown argument errors: compare your document with the signatures in Queries; most lists use limit/cursor, not page/perPage.
Was this helpful?