Building a notion-powered blog in next.js (my way)

TechnologyBlogNotionTypeScriptNextjs

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:

settingsmanage data sourcescopy 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:

  1. add your env vars
  2. push code
  3. hit deploy

that's it.

notion updates automatically flow through.

kinda magical.


common ways i broke this

  • used database id instead of data source idnothing 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.