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.
/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 deskEditors 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 when | Use 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 code | Example | Use |
|---|---|---|
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. |
body | Rich text | Main article body. |
featured_image | Media reference | Article hero image. |
The field codes are your frontend contract. Custom values live in entry.data.
Build an article route#
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.
const entry = await client.content.getBySlug("blog_post", "first-post");
console.log(entry.slug); // entry metadata
console.log(entry.data.title); // content-model fieldGenerate types after model changes:
npx contoprix pull
npx contoprix generateOptional static generation#
For a small collection, list published entries and return their slugs.
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:
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#
| Mistake | Better approach |
|---|---|
| One CMS page for every blog post | Create one blog_post type and one /blog/[slug] route. |
Reading entry.title | Read entry.data.title. |
| Showing drafts publicly | Use normal client.content delivery methods, not preview. |
| Forgetting unique slugs | Make a clear, stable slug part of the entry workflow. |
| Trusting rich-text HTML | Use a safe renderer or sanitizer. |