Building a notion-powered blog in next.js (my way)
A practical guide to building a Next.js blog powered by Notion as a headless CMS. Covers setup, API integration, search, tag filtering, pagination with shadcn/ui, and deployment to Vercel.
this whole thing started because i got tired of hardcoding blog posts like it's 2014.
notion already had all my notes, drafts, half-baked ideas… so why not treat it as my CMS?
what followed was a bunch of trial, error, and one too many secret_ tokens.
so here's the version i wish someone had handed me earlier.
before you dive in
make sure you've got:
- node 18+ (or bun if you like speed)
- a notion account
- a next.js 15+ project
- enough patience to fight APIs that only return 100 items at a time
setting up notion (aka convincing notion to be your headless cms)
step 1: build your blog database
open notion → make a database → add properties:
- title – your post title
- slug – the URL name (lowercase-hyphens)
- date – published date
- published – checkbox for visibility
- description – short summary
- tags – multi-select
fill in a couple sample posts (future-you will thank you).
step 2: create an integration
go to Notion Integrations → make a new one.
name it something cool like "blog cms".
enable read permissions.
copy the token that starts with ntn_ (feels like you're stealing your own data).
step 3: link integration to database
go back to your database → ••• menu → add connections → select your integration.
step 4: get the data source id
the actual piece everyone forgets:
settings → manage data sources → copy data source id.
that's the uuid thing that looks dangerous.
spinning up next.js
create a new project or plug this into whatever app folder you already have.
# Using bun
bunx create-next-app@latest my-notion-blog --typescript --tailwind --app
# Using npm
npx create-next-app@latest my-notion-blog --typescript --tailwind --app
cd my-notion-blog
(i still mess up the flags on the first try.)
installing the stuff that makes it work
# Using bun
bun add @notionhq/client notion-to-md react-markdown remark-gfm use-debounce lucide-react server-only
# Using npm
npm install @notionhq/client notion-to-md react-markdown remark-gfm use-debounce lucide-react server-only
add shadcn/ui components. make sure to grab the pagination component here:
# Initialize shadcn/ui
bunx shadcn@latest init
# Add required components (including pagination!)
bunx shadcn@latest add button input badge card skeleton pagination
environment magic
create .env.local:
NOTION_TOKEN="ntn_your_integration_token_here"
NOTION_DATA_SOURCE_ID="your-data-source-id-here"
and make sure it's .gitignore'd unless you enjoy leaking secrets.
wiring up the notion api
here's where you build a tiny wrapper around notion's api.
it's basically fetch calls + markdown conversion.
you create lib/notion.ts and set up helpers:
- fetch lists of posts
- fetch tags
- fetch one post
- convert notion blocks → markdown
this layer feels boring but it's the backbone.
import { NotionToMarkdown } from 'notion-to-md';
import 'server-only';
const NOTION_API = '
https://api.notion.com/v1
';
const NOTION_VERSION = '2022-06-28';
interface NotionResponse {
results: Array<{
id: string;
properties: Record<string, unknown>;
[key: string]: unknown;
}>;
[key: string]: unknown;
}
interface NotionPage {
id: string;
properties: Record<string, any>;
}
async function notionFetch(
endpoint: string,
options: { method?: string; body?: object } = {}) {
const response = await fetch(`${NOTION_API}${endpoint}`, {
method: options.method || 'POST',
headers: {
'Authorization': `Bearer ${process.env.NOTION_TOKEN}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json',
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Notion API Error: ${error.message || 'Unknown error'}`);
}
return response.json() as Promise<NotionResponse>;
}
// Create notion client for markdown conversion
const notionClient = {
blocks: {
children: {
list: async ({ block_id }: { block_id: string }) => {
return notionFetch(`/blocks/${block_id}/children`, { method: 'GET' });
}
}
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const n2m = new NotionToMarkdown({ notionClient: notionClient as any });
export interface PostMetadata {
id: string;
title: string;
slug: string;
date: string;
tags: string[];
description: string;
published: boolean;
}
export interface Post extends PostMetadata {
content: string;
}
export interface PaginatedPosts {
posts: PostMetadata[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
function getPageMetaData(page: NotionPage): PostMetadata {
const properties =
page.properties
;
let title = 'Untitled';
const titleProp = properties.Title || properties.title;
if (titleProp?.type === 'title' && titleProp.title?.[0]) {
title = titleProp.title[0].plain_text || 'Untitled';
}
let slug = '';
const slugProp = properties.Slug || properties.slug;
if (slugProp?.type === 'rich_text' &&
slugProp.rich
_text?.[0]) {
slug =
slugProp.rich
_text[0].plain_text || '';
}
let date = '';
const dateProp =
properties.Date
||
properties.date
;
if (dateProp?.type === 'date' &&
dateProp.date
) {
date =
dateProp.date
.start || '';
}
let tags: string[] = [];
const tagsProp = properties.Tags || properties.tags;
if (tagsProp?.type === 'multi_select' && tagsProp.multi_select) {
tags = tagsProp.multi_
select.map
((tag: any) =>
tag.name
);
}
let description = '';
const descProp = properties.Description || properties.description;
if (descProp?.type === 'rich_text' &&
descProp.rich
_text?.[0]) {
description =
descProp.rich
_text[0].plain_text || '';
}
let published = false;
const publishedProp = properties.Published || properties.published;
if (publishedProp?.type === 'checkbox') {
published = publishedProp.checkbox;
}
return { id:
page.id
, title, slug, date, tags, description, published };
}
export async function getAllPublished(): Promise<PostMetadata[]> {
const response = await notionFetch(
`/databases/${process.env.NOTION_DATA_SOURCE_ID}/query`,
{
method: 'POST',
body: {
filter: {
property: 'Published',
checkbox: { equals: true },
},
sorts: [
{ property: 'Date', direction: 'descending' },
],
},
}
);
return
response.results.map
((page) => getPageMetaData(page as NotionPage));
}
export async function getAllTags(): Promise<string[]> {
const posts = await getAllPublished();
const tagsSet = new Set<string>();
posts.forEach(post => {
post.tags.forEach(tag => tagsSet.add(tag));
});
return Array.from(tagsSet).sort();
}
export async function getSinglePost(slug: string): Promise<Post | null> {
if (!slug || slug.trim() === '') {
return null;
}
const allPosts = await getAllPublished();
const matchingPost = allPosts.find(
post => post.slug.toLowerCase() === slug.toLowerCase()
);
if (!matchingPost) {
return null;
}
const mdBlocks = await n2m.pageToMarkdown(
matchingPost.id
);
const mdString = n2m.toMarkdownString(mdBlocks);
return {
...matchingPost,
content: mdString.parent,
};
}
export async function getFilteredPosts(
page: number = 1,
pageSize: number = 10,
search?: string,
tag?: string): Promise<PaginatedPosts> {
let posts = await getAllPublished();
if (search?.trim()) {
const searchLower = search.toLowerCase();
posts = posts.filter(post =>
post.title.toLowerCase().includes(searchLower) ||
post.description.toLowerCase().includes(searchLower)
);
}
if (tag?.trim()) {
posts = posts.filter(post =>
post.tags.some(t => t.toLowerCase() === tag.toLowerCase())
);
}
const total = posts.length;
const totalPages = Math.ceil(total / pageSize);
const start = (page - 1) * pageSize;
const paginatedPosts = posts.slice(start, start + pageSize);
return { posts: paginatedPosts, total, page, pageSize, totalPages };
}
export async function getAllSlugs(): Promise<string[]> {
const posts = await getAllPublished();
return
posts.map
((post) => post.slug);
}
ui bits: search, tags, pagination
these three felt like the final boss fight:
search
client-side, debounced input.
type → updates ?search= query param.
fast, simple.
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
import { Search as SearchIcon, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export function Search({ placeholder = 'Search posts...' }: { placeholder?: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const [inputValue, setInputValue] = useState(searchParams.get('search') || '');
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
params.set('page', '1');
if (term?.trim()) {
params.set('search', term);
} else {
params.delete('search');
}
replace(`${pathname}?${params.toString()}`, { scroll: false });
}, 300);
const handleClear = () => {
setInputValue('');
const params = new URLSearchParams(searchParams);
params.delete('search');
params.set('page', '1');
replace(`${pathname}?${params.toString()}`, { scroll: false });
};
useEffect(() => {
const search = searchParams.get('search');
if (!search) setInputValue('');
}, [searchParams]);
return (
<div className="relative w-full max-w-md">
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder={placeholder}
value={inputValue}
onChange={(e) => {
setInputValue(
e.target
.value);
handleSearch(
e.target
.value);
}}
className="pl-10 pr-10"
/>
{inputValue && (
<Button
variant="ghost"
size="icon"
onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
);
}
tag filter
click a tag → toggles ?tag=.
honestly my favorite part.
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
interface TagFilterProps {
tags: string[];
}
export function TagFilter({ tags }: TagFilterProps) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const currentTag = searchParams.get('tag');
const handleTagClick = (tag: string) => {
const params = new URLSearchParams(searchParams);
params.set('page', '1');
if (currentTag === tag) {
params.delete('tag');
} else {
params.set('tag', tag);
}
replace(`${pathname}?${params.toString()}`, { scroll: false });
};
const handleClearTag = () => {
const params = new URLSearchParams(searchParams);
params.delete('tag');
params.set('page', '1');
replace(`${pathname}?${params.toString()}`, { scroll: false });
};
if (tags.length === 0) return null;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Filter by Tag</h3>
{currentTag && (
<Button
variant="ghost"
size="sm"
onClick={handleClearTag}
className="h-auto py-1 px-2 text-xs"
>
<X className="h-3 w-3 mr-1" />
Clear filter
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
{
tags.map
((tag) => (
<Badge
key={tag}
variant={currentTag === tag ? 'default' : 'outline'}
className="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => handleTagClick(tag)}
>
{tag}
</Badge>
))}
</div>
</div>
);
}
pagination
instead of building custom buttons, we simply wrap the official shadcn components. it still uses query params, but it looks much sharper.
create components/blog/pagination.tsx:
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import {
Pagination as PaginationRoot,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
interface PaginationProps {
currentPage: number;
totalPages: number;
}
export function Pagination({ currentPage, totalPages }: PaginationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const createPageURL = (pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
if (totalPages <= 1) return null;
// Logic to determine which page numbers to show
const getPageNumbers = () => {
const delta = 2;
const range = [];
const rangeWithDots = [];
for (
let i = Math.max(2, currentPage - delta);
i <= Math.min(totalPages - 1, currentPage + delta);
i++
) {
range.push(i);
}
if (currentPage - delta > 2) {
rangeWithDots.push(1, '...');
} else {
rangeWithDots.push(1);
}
rangeWithDots.push(...range);
if (currentPage + delta < totalPages - 1) {
rangeWithDots.push('...', totalPages);
} else if (totalPages > 1) {
rangeWithDots.push(totalPages);
}
return rangeWithDots;
};
const pages = getPageNumbers();
return (
<PaginationRoot>
<PaginationContent>
{/* Previous Button */}
<PaginationItem>
<PaginationPrevious
href={currentPage > 1 ? createPageURL(currentPage - 1) : '#'}
aria-disabled={currentPage <= 1}
tabIndex={currentPage <= 1 ? -1 : undefined}
className={currentPage <= 1 ? "pointer-events-none opacity-50" : undefined}
/>
</PaginationItem>
{/* Page Numbers */}
{
pages.map
((page, index) => (
<PaginationItem key={index}>
{page === '...' ? (
<PaginationEllipsis />
) : (
<PaginationLink
href={createPageURL(page as number)}
isActive={page === currentPage}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
{/* Next Button */}
<PaginationItem>
<PaginationNext
href={currentPage < totalPages ? createPageURL(currentPage + 1) : '#'}
aria-disabled={currentPage >= totalPages}
tabIndex={currentPage >= totalPages ? -1 : undefined}
className={currentPage >= totalPages ? "pointer-events-none opacity-50" : undefined}
/>
</PaginationItem>
</PaginationContent>
</PaginationRoot>
);
}
building the actual pages
/blog
this is the list page.
shows:
- search bar
- tag filters
- posts in a grid
- pagination
it's basically a tiny feed reader.
import Link from 'next/link';
import { getFilteredPosts, getAllTags } from '@/lib/notion';
import { Search } from '@/components/blog/search';
import { TagFilter } from '@/components/blog/tag-filter';
import { Pagination } from '@/components/blog/pagination';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
export const revalidate = 60; // Revalidate every 60 seconds
interface BlogPageProps {
searchParams: Promise<{
page?: string;
search?: string;
tag?: string;
}>;
}
export default async function BlogPage({ searchParams }: BlogPageProps) {
const params = await searchParams;
const page = Number(
params.page
) || 1;
const search =
params.search
|| '';
const tag = params.tag || '';
const [{ posts, total, totalPages }, allTags] = await Promise.all([
getFilteredPosts(page, 9, search, tag),
getAllTags(),
]);
return (
<div className="min-h-screen py-16 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="mb-12">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Blog
</h1>
<p className="text-lg text-muted-foreground">
{total} {total === 1 ? 'post' : 'posts'}
{search && ` matching "${search}"`}
{tag && ` tagged with "${tag}"`}
</p>
</div>
<div className="mb-8 space-y-6">
<Search placeholder="Search posts by title or content..." />
<TagFilter tags={allTags} />
</div>
{posts.length === 0 ? (
<Card className="text-center py-16">
<CardContent className="pt-6">
<p className="text-xl text-muted-foreground">
No posts found
{(search || tag) && '. Try adjusting your filters.'}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-12">
{
posts.map
((post) => (
<Card key={
post.id
} className="flex flex-col hover:shadow-lg transition-shadow">
<CardHeader>
<Link href={`/blog/${post.slug}`}>
<CardTitle className="hover:text-primary transition-colors">
{post.title}
</CardTitle>
</Link>
<CardDescription className="line-clamp-3">
{post.description}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-end">
<div className="flex items-center justify-between text-sm">
<time className="text-muted-foreground">
{new Date(
post.date
).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
{post.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{post.tags.slice(0, 2).map((tagName) => (
<Badge key={tagName} variant="secondary">
{tagName}
</Badge>
))}
{post.tags.length > 2 && (
<Badge variant="outline">
+{post.tags.length - 2}
</Badge>
)}
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
{totalPages > 1 && (
<Pagination currentPage={page} totalPages={totalPages} />
)}
</div>
</div>
);
}
/blog/[slug]
markdown → react-markdown → beautiful readable page.
add badges, date formatting, a big title.
looks clean with shadcn.
import { Metadata } from "next";
import { notFound } from "next/navigation";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { getSinglePost, getAllSlugs } from "@/lib/notion";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { CalendarIcon } from "lucide-react";
export const revalidate = 60;
export async function generateStaticParams() {
const slugs = await getAllSlugs();
return
slugs.map
((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getSinglePost(slug);
if (!post) {
return { title: "Post Not Found" };
}
return {
title: post.title,
description: post.description,
};
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getSinglePost(slug);
if (!post) {
notFound();
}
return (
<div className="min-h-screen py-16 px-4 sm:px-6 lg:px-8">
<article className="max-w-3xl mx-auto">
<Card className="p-8 mb-8">
<header className="mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-6">
{post.title}
</h1>
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CalendarIcon className="h-4 w-4" />
<time dateTime={
post.date
}>
{formatDate(
post.date
)}
</time>
</div>
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{
post.tags.map
((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
)}
</div>
{post.description && (
<p className="text-lg text-muted-foreground leading-relaxed">
{post.description}
</p>
)}
</header>
</Card>
<div className="prose prose-lg dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{post.content}
</ReactMarkdown>
</div>
</article>
</div>
);
}
deploying (vercel moment)
on vercel:
- add your env vars
- push code
- hit deploy
that's it.
notion updates automatically flow through.
kinda magical.
common ways i broke this
- used database id instead of data source id → nothing works.
- forgot to check "Published" → posts vanished.
- forgot slugs → 404 city.
- expected dev mode caching → doesn't exist.
extra stuff you can tweak
- posts per page
- revalidate time
- add new notion properties
- extend markdown styling
- using @uitodev/usehooks for the debounce method
const { posts, total, totalPages } = await getFilteredPosts(
page,
12, // Change from 9 to 12 posts per page
search,
tag);
this setup is flexible in all the right ways.
wrapping up
i started this because i wanted my blog system to feel… lightweight.
notion already holds my brain.
next.js already holds my code.
connecting the two felt obvious in hindsight.
there's still a lot to learn and build.