How to Use the Notion API as a CMS in a Next.js App
Notion can be a very practical CMS for a Next.js app, especially when you want to manage blog posts, portfolio projects, experience, education, and skills without building a full admin dashboard.
Instead of hardcoding content in JSON or Markdown, you write and organize everything in Notion. Your Next.js app fetches that content through the Notion API, maps it into clean TypeScript objects, and renders it on your website.
In this tutorial, we will use the same pattern as this portfolio project( i developer this portfolio with notion API): Next.js, @notionhq/client, server-only fetching, a repository layer, Notion block rendering, and fallback content.
What we are building
We will set up Notion as a backend CMS for:
- Blog posts
- Projects Section
The goal is simple:
- Write content in Notion
- Fetch it safely from Next.js
- Render it dynamically
- Cache content for performance
Your can find full source code in this repository
git clone https://github.com/Hrabi80/myPortfolio.gitWhy use Notion as a CMS?
Notion is useful when your content is structured but you do not want to build a custom admin panel providing a clean writing interfacean with easy editing without deploying new code
It works well for:
Notion gives you:
- Tables/databases for structured data
- Tags, dates, status fields, and covers
- Page content with headings, lists, code blocks, images, and callouts
Notion is not a replacement for a transactional database. Do not use it for payments, carts, authentication, permissions, or high-frequency writes. Use it for content.
1. Create a Notion integration
First, create an internal Notion integration.
Steps:
- Go to Notion integrations.
- Create a new internal integration.
- Give it a clear name, for example: Portfolio CMS.
- Copy the integration secret.
- Open the Notion database you want your app to read.
- Click Share.
- Invite your integration to the database.
If you skip the sharing step, your API calls may work technically, but return empty results because the integration does not have access.
2. Create your Notion databases
I recommend creating one database per content type.
For a blog database, use properties like:
- title: title property
- slug: rich text
- summary: rich text
- tags: multi-select
- status: status or select
- date: date
- source: select, for example notion or medium
- canonical_url: URL, optional
- source_url: URL, optional
For a projects database:
- name
- slug
- summary
- description
- tags
- githubUrl
- live
- coverImage
- gallery
- status
- publishedAt
3. Add environment variables
In your Next.js project, create .env.local.
NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxx
NOTION_BLOG_DATABASE_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_PROJECTS_DATABASE_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_SITE_URL=http://localhost:30004. Install the Notion SDK
pnpm add @notionhq/client
5. Create a server-only Notion client
Create a file like:
// src/lib/notion.ts
import "server-only";
import { Client } from "@notionhq/client";
let notionClient: Client | null = null;
const getNotionClient = () => {
const apiKey = process.env.NOTION_TOKEN;
if (!apiKey) {
throw new Error("NOTION_TOKEN is missing");
}
if (!notionClient) {
notionClient = new Client({ auth: apiKey });
}
return notionClient;
};The server-only import helps prevent this file from being imported inside client components.
6. Query a Notion database
In newer Notion API versions, databases contain data sources. You can retrieve the database, get its first data source ID, then query that data source.
import { cache } from "react";
import type { QueryDataSourceParameters } from "@notionhq/client";
export const getDataSourceId = cache(async (databaseId: string) => {
const db = await getNotionClient().databases.retrieve({
database_id: databaseId,
});
const dataSources = (db as { data_sources?: Array<{ id: string }> })
.data_sources;
const firstId = dataSources?.[0];
if (!firstId) {
throw new Error("No data source found for this database");
}
return firstId.id;
});
export const getDatabase = cache(
async (
databaseId: string,
query?: Omit<QueryDataSourceParameters, "data_source_id">
) => {
const dataSourceId = await getDataSourceId(databaseId);
return getNotionClient().dataSources.query({
data_source_id: dataSourceId,
...(query ?? {}),
});
}
);7. Create a repository for blog posts
Do not call Notion directly from your React components. Keep Notion logic inside a repository.
export class NotionBlogRepository {
constructor(private readonly databaseId: string) {}
async listMeta() {
const res = await getDatabase(this.databaseId, {
filter: {
property: "status",
status: { equals: "published" },
},
sorts: [{ property: "date", direction: "descending" }],
});
return res.results
.filter(isPageObject)
.map(mapToBlogMeta);
}
async getBySlug(slug: string) {
const res = await getDatabase(this.databaseId, {
filter: {
and: [
{ property: "slug", rich_text: { equals: slug } },
{ property: "status", status: { equals: "published" } },
],
},
page_size: 1,
});
const page = res.results.find(isPageObject);
if (!page) {
return null;
}
const meta = mapToBlogMeta(page);
const blocks = await getPageBlockTree(page.id);
return {
...meta,
source: "notion",
blocks,
};
}
}This makes your app easier to maintain because the UI does not care whether content comes from Notion, JSON, Markdown, or another CMS later.
8. Map Notion properties into clean objects
Notion responses are flexible, but your app should be strict.
Example mapper:
export const mapToBlogMeta = (page: PageObjectResponse): BlogMeta => {
return {
id: page.id,
slug: getRichText(page, "slug"),
title: getRichText(page, "title"),
summary: getRichText(page, "summary"),
coverImage: getCover(page),
tags: getTags(page, "tags"),
status:
(getRichText(page, "status") as "published" | "unpublished") ??
"unpublished",
publishedAt: getDate(page, "date"),
source: "notion",
canonicalUrl: getUrl(page, "canonical_url"),
};
};This gives your components predictable data.
Instead of passing raw Notion responses everywhere, your UI receives something simple like:
type BlogMeta = {
id: string;
slug: string;
title: string;
summary: string;
coverImage?: string;
tags: Tag[];
status?: "published" | "unpublished";
publishedAt: string;
source: "notion" | "medium";
canonicalUrl?: string;
};9. Fetch Notion page blocks
A database row gives you metadata. The article body lives inside the Notion page as blocks.
You need to fetch those blocks separately.
export const getBlocks = cache(async (block_id: string) => {
const blocks: BlockObjectResponse[] = [];
let cursor: string | undefined;
do {
const res = await getNotionClient().blocks.children.list({
block_id,
start_cursor: cursor,
page_size: 100,
});
blocks.push(
...res.results.filter((block): block is BlockObjectResponse => {
return "type" in block;
})
);
cursor = res.has_more ? res.next_cursor ?? undefined : undefined;
} while (cursor);
return blocks;
});Some blocks can have children, such as columns, toggles, and nested lists. For that, use a recursive function:
export const getBlockTree = async (rootBlockId: string) => {
const directChildren = await getBlocks(rootBlockId);
return Promise.all(
directChildren.map(async (block) => {
const node = block as BlockObjectResponse & {
children?: unknown[];
};
if (block.has_children) {
node.children = await getBlockTree(block.id);
}
return node;
})
);
};10. Render Notion blocks in React
After fetching blocks, map them into your own block model and render them.
A simple renderer can support:
- Headings
- Lists
- Paragraphs
- Code blocks
- Dividers
- Images
- Quotes
- Columns
- Callouts
Example:
export function NotionRenderer({ blocks }: { blocks: ContentBlock[] }) {
if (!blocks.length) {
return null;
}
return (
<article>
{blocks.map((block) => {
if (block.type === "paragraph") {
return (
<p key={block.id}>
{block.rich_text.map((span) => span.text).join("")}
</p>
);
}
if (block.type === "heading") {
return (
<h2 key={block.id}>
{block.rich_text.map((span) => span.text).join("")}
</h2>
);
}
if (block.type === "code") {
return (
<pre key={block.id}>
<code>
{block.rich_text.map((span) => span.text).join("")}
</code>
</pre>
);
}
return null;
})}
</article>
);
}In a production app, you should also support rich text formatting like bold, italic, links, inline code, and colors.
11. Use it in Next.js App Router
With the App Router, fetch content in server components.
export const revalidate = 60;
export async function generateStaticParams() {
const posts = await fetchBlogs();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await fetchBlogBySlug(slug);
if (!post) {
return <BlogNotFound />;
}
return <BlogPostContent post={post} />;
}This keeps your Notion API call on the server.
12. Add fallback content
Notion is an external service. Your app should still work if:
- The Notion API is down
- Your token is missing
- A database property was renamed
- You are building locally without Notion access
This section is just a defensive programming pattern and fallback if something from list above happened
Best practices
- Keep the Notion token server-only.
- Never import Notion code into client components.
- Filter by status = published.
- Sort by order and by date as fallback in the Notion query.
- Cache responses with cache, revalidate, or another caching layer.
- Use a repository layer instead of calling Notion in UI components.
- Map Notion responses into your own TypeScript types.
- Be careful with Notion-hosted file URLs because they can expire Your better use other free CDN to store your images for faster rendering and durability.
Conclusion
Notion is a great CMS choice for small and medium content-driven apps. It lets you manage blog posts, website content from a clean interface without building an admin panel.
References:
