Search documentation

Search documentation

Pages

Dynamic Pages

Render repeatable routes such as article and product details.

What a dynamic page is#

A dynamic page is one frontend template that renders many content entries. It is a routing pattern in your application, not a different kind of Visual Builder page.

One route, many entries
/blog/first-post    -> blog_post entry with slug first-post
/blog/release-notes -> blog_post entry with slug release-notes
/products/desk      -> product entry with slug desk

Editors create content entries; your route reads the URL slug and renders the matching entry. This avoids creating a separate CMS page for every article or product.

When to use it#

Use a CMS page whenUse a dynamic route when
Each URL needs its own block arrangement.Every URL shares the same React layout.
It is a campaign, landing, or About page.It is an article, product, author, or help article.
Editors need layout freedom per URL.Editors mainly change structured record data.

A common setup uses both: /blog is a CMS page with a featured-post block, while /blog/[slug] renders one blog entry.

Model the content first#

Create a content type with a stable code, such as blog_post.

Field codeExampleUse
title"How to plan a content model"Heading and metadata.
slug"plan-a-content-model"URL segment.
summary"A beginner-friendly guide..."List and social-preview text.
bodyRich textMain article body.
featured_imageMedia referenceArticle hero image.

The field codes are your frontend contract. Custom values live in entry.data.

Build an article route#

app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { createContoprixClient } from "@contoprix/next/server";

type BlogPostData = {
  title?: string;
  summary?: string;
  body?: unknown;
};

type ArticlePageProps = {
  params: Promise<{ slug: string }>;
};

export default async function ArticlePage({ params }: ArticlePageProps) {
  const { slug } = await params;
  const client = createContoprixClient();

  try {
    const entry = await client.content.getBySlug("blog_post", slug, {
      languageCode: "en",
    });

    const post = entry.data as BlogPostData;

    return (
      <article>
        <h1>{post.title ?? "Untitled article"}</h1>
        {post.summary ? <p>{post.summary}</p> : null}
        {/*
          Render post.body with the safe rich-text renderer used by your app.
          Do not assume rich text is safe HTML.
        */}
      </article>
    );
  } catch (error) {
    const statusCode =
      typeof error === "object" && error !== null && "statusCode" in error
        ? (error as { statusCode?: number }).statusCode
        : undefined;

    if (statusCode === 404) notFound();
    throw error;
  }
}

getBySlug uses one content-entry slug. It is ideal for /blog/my-article, not a nested catch-all content URL.

Read entry data safely#

Delivered entries include metadata such as id, slug, contentTypeCode, and publishedAt. Model fields are inside data.

Correct data access
const entry = await client.content.getBySlug("blog_post", "first-post");

console.log(entry.slug);       // entry metadata
console.log(entry.data.title); // content-model field

Generate types after model changes:

Terminal
npx contoprix pull
npx contoprix generate

Optional static generation#

For a small collection, list published entries and return their slugs.

app/blog/[slug]/page.tsx
import { createContoprixClient } from "@contoprix/next/server";

export async function generateStaticParams() {
  const client = createContoprixClient();
  const { items } = await client.content.list({
    contentType: "blog_post",
    languageCode: "en",
    take: 100,
  });

  return items
    .filter((entry) => entry.slug)
    .map((entry) => ({ slug: entry.slug! }));
}

For a large collection, page through results or render dynamically. Do not silently omit entries because one list request was limited.

Localization and caching#

Request the language belonging to the current URL or locale:

Locale-aware request
const entry = await client.content.getBySlug("blog_post", slug, {
  languageCode: locale,
});

An explicit language needs a published entry in that language. Treat a missing translation as a 404 or implement an intentional fallback policy.

Dynamic pages should always fetch normal published delivery data. Use time-based revalidation, a publish webhook, or both so published changes reach visitors quickly.

Common mistakes#

MistakeBetter approach
One CMS page for every blog postCreate one blog_post type and one /blog/[slug] route.
Reading entry.titleRead entry.data.title.
Showing drafts publiclyUse normal client.content delivery methods, not preview.
Forgetting unique slugsMake a clear, stable slug part of the entry workflow.
Trusting rich-text HTMLUse a safe renderer or sanitizer.