If you have any queries regarding my academic projects or would like to discuss them with me, please feel free to reach out!
© 2026 Tushar Pankhaniya. All rights reserved.
A simple guide to building a dynamic blog with Notion and Next.js
If you're a developer looking for a fast, flexible, and free CMS for your blog, using Notion as a content management system with Next.js is one of the best setups in 2026.
In this article, I'll walk you through exactly how I integrated Notion with Next.js to power the blog section of my portfolio — including how I set up the API, fetched blog posts, implemented dynamic routing, and optimized everything for SEO.
💡 Why Notion + Next.js?
Notion provides an intuitive interface for content creation, while Next.js delivers blazing-fast performance with server-side rendering and static site generation. Together, they create a powerful, developer-friendly blogging solution.
When I was building my portfolio, I wanted a blogging solution that would meet these criteria:
Notion checked all these boxes. It's free, has an excellent API, and provides a beautiful writing experience. Combined with Next.js's static generation capabilities, it's a match made in heaven.

By the end of this tutorial, you'll be able to:
Before we dive in, make sure you have:
⚠️ Note: This tutorial uses Next.js 14 with the App Router. If you're using the Pages Router, some syntax may differ slightly.
First, we need to create a database in Notion that will serve as our blog's content store.
Here are the properties I recommend for your blog database:
| Property Name | Type | Description |
|---|---|---|
| Title | Title | The blog post title (default property) |
| Slug | Text | URL-friendly version of the title |
| Status | Select | Draft, Published, Archived |
| Published Date | Date | When the post was published |
| Tags | Multi-select | Categories or topics |
| Description | Text | Short summary for SEO |
| Cover Image | Files & Media | Hero image for the post |
✅ Pro Tip: Always use a "Status" property to control which posts appear on your live site. Only fetch posts with status "Published" in your production build.

To access your Notion database from Next.js, you'll need to create an integration and get your API credentials.
🔒 Security Note: Never commit your API key to GitHub! We'll use environment variables to keep it secure.
You'll also need your database ID. Here's how to find it:
notion.so/your-workspace/[DATABASE_ID]?v=...Example: If your URL is https://notion.so/myworkspace/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6?v=...
Your Database ID is: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Now let's set up the Next.js side of things.
We'll use the official Notion SDK for JavaScript:
bun add @notionhq/client
bun add notion-to-md
bun add react-markdownPackage breakdown:
@notionhq/client — Official Notion SDKnotion-to-md — Converts Notion blocks to Markdownreact-markdown — Renders Markdown in ReactCreate a .env.local file in your project root:
NOTION_TOKEN=your_integration_token_here
NOTION_DATABASE_ID=your_database_id_hereLet's create a utility file to handle all our Notion API interactions.
Create lib/notion.js:
import { Client } from "@notionhq/client";
import { NotionToMarkdown } from "notion-to-md";
// Initialize Notion client
const notion = new Client({
auth: process.env.NOTION_TOKEN,
});
// Initialize Notion to Markdown converter
const n2m = new NotionToMarkdown({ notionClient: notion });
export const getDatabase = async () => {
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID,
filter: {
property: "Status",
select: {
equals: "Published",
},
},
sorts: [
{
property: "Published Date",
direction: "descending",
},
],
});
return response.results;
};
export const getPage = async (pageId) => {
const response = await notion.pages.retrieve({ page_id: pageId });
return response;
};
export const getBlocks = async (blockId) => {
const mdblocks = await n2m.pageToMarkdown(blockId);
const mdString = n2m.toMarkdownString(mdblocks);
return mdString.parent;
};This file provides three key functions:
getDatabase() — Fetches all published blog postsgetPage(pageId) — Fetches a specific page's metadatagetBlocks(blockId) — Converts page content to MarkdownNow let's create a page that displays all your blog posts.
Create app/blog/page.jsx:
import { getDatabase } from "@/lib/notion";
import Link from "next/link";
export default async function BlogPage() {
const posts = await getDatabase();
return (
<div className="max-w-4xl mx-auto px-4 py-16">
<h1 className="text-4xl font-bold mb-8">Blog Posts</h1>
<div className="grid gap-8">
{posts.map((post) => {
const title = post.properties.Title.title[0].plain_text;
const slug = post.properties.Slug.rich_text[0].plain_text;
const description = post.properties.Description.rich_text[0]?.plain_text;
const date = post.properties["Published Date"].date.start;
return (
<Link key={post.id} href={`/blog/${slug}`}>
<article className="border p-6 rounded-lg hover:shadow-lg transition">
<h2 className="text-2xl font-bold mb-2">{title}</h2>
<p className="text-gray-600 mb-4">{description}</p>
<time className="text-sm text-gray-500">
{new Date(date).toLocaleDateString()}
</time>
</article>
</Link>
);
})}
</div>
</div>
);
}🎉 This is a Server Component in Next.js 15, which means the data is fetched on the server and the page is statically generated at build time for optimal performance!

Now for the exciting part — creating individual blog post pages with dynamic routing.
Create app/blog/[slug]/page.jsx:
import { getDatabase, getPage, getBlocks } from "@/lib/notion";
import ReactMarkdown from "react-markdown";
// Generate static paths for all blog posts
export async function generateStaticParams() {
const posts = await getDatabase();
return posts.map((post) => ({
slug: post.properties.Slug.rich_text[0].plain_text,
}));
}
export default async function BlogPost({ params }) {
const { slug } = params;
// Find the post with matching slug
const posts = await getDatabase();
const post = posts.find(
(p) => p.properties.Slug.rich_text[0].plain_text === slug
);
if (!post) {
return <div>Post not found</div>;
}
// Get post content
const markdown = await getBlocks(post.id);
const title = post.properties.Title.title[0].plain_text;
const date = post.properties["Published Date"].date.start;
return (
<article className="max-w-3xl mx-auto px-4 py-16">
<h1 className="text-4xl font-bold mb-4">{title}</h1>
<time className="text-gray-600 mb-8 block">
{new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<div className="prose lg:prose-xl">
<ReactMarkdown>{markdown}</ReactMarkdown>
</div>
</article>
);
}Key Features:
generateStaticParams() — Pre-generates all blog post pages at build time[slug] — Each post gets its own URLReactMarkdown — Converts Markdown to beautiful HTMLLet's make sure our blog posts are SEO-friendly with proper meta tags.
Update app/blog/[slug]/page.jsx to include metadata generation:
// Add this export for dynamic metadata
export async function generateMetadata({ params }) {
const { slug } = params;
const posts = await getDatabase();
const post = posts.find(
(p) => p.properties.Slug.rich_text[0].plain_text === slug
);
if (!post) return {};
const title = post.properties.Title.title[0].plain_text;
const description = post.properties.Description.rich_text[0]?.plain_text;
const coverImage = post.properties["Cover Image"]?.files[0]?.file?.url;
return {
title,
description,
openGraph: {
title,
description,
images: [coverImage],
type: "article",
},
twitter: {
card: "summary_large_image",
title,
description,
images: [coverImage],
},
};
}This automatically generates:
🔎 SEO Tip: Next.js 14's
generateMetadatafunction is automatically cached, so these calls don't slow down your build process!
The prose from @tailwindcss/typography handles basic markdown styling, but let's add some custom styles.
Add to your globals.css:
/* Custom prose styling for blog posts */
.prose {
max-width: 65ch;
}
.prose h2 {
margin-top: 2em;
margin-bottom: 1em;
font-weight: 700;
}
.prose code {
background-color: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.875em;
}
.prose pre {
background-color: #2d2d2d;
color: #e6e6e6;
border-radius: 8px;
padding: 1.5em;
}
.prose img {
border-radius: 8px;
margin: 2em 0;
}Time to make your blog live! I recommend deploying with Vercel (the creators of Next.js).
NOTION_TOKENNOTION_DATABASE_ID🚀 That's it! Vercel will automatically rebuild your site whenever you push changes to GitHub.
When you publish a new blog post in Notion, you'll need to trigger a rebuild. You can:
Here are some ways to make your blog even faster:
Use Next.js Image component for automatic optimisation:
import Image from "next/image";
<Image
src={coverImage}
alt={title}
width={1200}
height={630}
priority
/>Add ISR to automatically update pages every few minutes:
export const revalidate = 3600; // Revalidate every hour
Create a loading.jsx file for better UX:
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
);
}Solution: Always use optional chaining when accessing Notion properties:
// Instead of this:
const description = post.properties.Description.rich_text[0].plain_text;
// Do this:
const description = post.properties.Description.rich_text[0]?.plain_text || "";Solution: Use static generation to minimize API calls:
generateStaticParamsSolution: Optimize your build process:
Congratulations! You've successfully integrated Notion as a CMS for your Next.js blog. Here's what you've accomplished:
💪 What's Next?
Consider adding these enhancements:
Search functionality
Tag filtering
Related posts
Comments system
Newsletter integration
Analytics tracking
"The best way to learn is by building. Start writing your first blog post in Notion today and watch your portfolio come to life!"