Learn how we used Notion as a headless CMS, Next.js 15, Supabase and Vercel to ship Zeroclick.app’s blog in just 1 hour.
Is it good for SEO ?Why Notion?Tech Stack🛠️ Step 1: Notion Setup🪛 Step 2: Install Dependencies💾 Step 3: Cache Notion Pages in Supabase📄 Step 4: The NotionPage Component🚀 Step 5: Routing in Next.js🚀 Step 6: Cron our app/api/fetch-notion-pages/route.ts🎁 Bonus: Blog Index Page

See our video on it here ⬆︎
Is it good for SEO ?
Our Lighthouse SEO score is already in the 90s—pretty good for a first pass!

Why Notion?
- Simple documentation tool we already use: Notion powers all our docs and pages. No new admin UI to build.
- Write directly in Notion: Antoine and I can draft posts, About, Privacy Policy, etc., in one workspace.
- Unified domain: All pages live under
zeroclick.app
—blog, about, legal—no subdomains or separate dashboards.
Tech Stack
- Framework: Next.js 15 (app router)
- Database: Supabase (or any PostgreSQL/DB)
- Hosting: Vercel (or any provider supporting Next.js)
- Notion API: Official client +
react-notion-x
¬ion-compat
🛠️ Step 1: Notion Setup
1 - Create a Notion integration and give it access to a parent “Blog” page.

2 - Parent Page: One Notion page holds all posts and pages as children.

3 - Add the integration on [YOUR WEBSITE] page.
This will apply the integration on all the subpage too. I lost 2h before I understand that.

4 - Sitemap table: Create a database to list each document (ID, slug, title, published flag, last modified).
This database is our single source of truth.

🪛 Step 2: Install Dependencies
pnpm add react-notion-x @notionhq/client notion-compat pnpm add -D notion-types
⚠️ If you hit the [email protected] bug, apply the PR fix or use pnpm patch per https://github.com/NotionX/react-notion-x/pull/629.
💾 Step 3: Cache Notion Pages in Supabase
We fetch pages from Notion, transform them for
react-notion-x
, then upsert into Supabase as JSON for fast SSR/ISR.The code
// app/api/fetch-notion-pages/route.ts iimport { NextResponse } from "next/server"; import { Client } from "@notionhq/client"; import { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints"; import { NotionCompatAPI } from "notion-compat"; import { createAdminClient } from "@/utils/supabase/server"; import { ExtendedRecordMap } from "notion-types"; // Configuration const CONFIG = { DATABASE_ID: "1f431632a35a80b0b9aad412a34843a7", PAGE_SIZE: 100, PROPERTY_NAMES: { PAGE_ID: "Page ID", PATHNAME: "Pathname", TITLE: "Title", DESCRIPTION: "Description", PUBLISHED: "Published", LAST_MODIFIED: "Last Modified", PARENT_PAGE_ID: "parentPageId", }, } as const; // Type definitions interface NotionPageData { pageId: string; pathname: string; isPublished: boolean; title: string; description: string; imageUrl: string; recordMap: ExtendedRecordMap; parentPageId: string; icon: string; lastModifiedAt: string | null; createdAt: string; updatedAt: string; } type NotionTitleProperty = { type: "title"; title: Array<{ plain_text: string }>; }; type NotionRichTextProperty = { type: "rich_text"; rich_text: Array<{ plain_text: string }>; }; type NotionCheckboxProperty = { type: "checkbox"; checkbox: boolean; }; type NotionDateProperty = { type: "date"; date: { start: string } | null; }; // Initialize Notion clients const notionClient = new Client({ auth: process.env.NOTION_TOKEN, }); const notionCompatAPI = new NotionCompatAPI( new Client({ auth: process.env.NOTION_TOKEN, }) ); // Utility functions const formatPageId = (pageId: string): string => { return `${pageId.slice(0, 8)}-${pageId.slice(8, 12)}-${pageId.slice( 12, 16 )}-${pageId.slice(16, 20)}-${pageId.slice(20)}`; }; const getPropertyValue = { title: (prop: NotionTitleProperty | unknown): string => { return prop && typeof prop === "object" && "type" in prop && prop.type === "title" ? (prop as NotionTitleProperty).title?.[0]?.plain_text || "" : ""; }, richText: (prop: NotionRichTextProperty | unknown): string => { return prop && typeof prop === "object" && "type" in prop && prop.type === "rich_text" ? (prop as NotionRichTextProperty).rich_text?.[0]?.plain_text || "" : ""; }, checkbox: (prop: NotionCheckboxProperty | unknown): boolean => { return prop && typeof prop === "object" && "type" in prop && prop.type === "checkbox" ? (prop as NotionCheckboxProperty).checkbox || false : false; }, date: (prop: NotionDateProperty | unknown): string | null => { return prop && typeof prop === "object" && "type" in prop && prop.type === "date" ? (prop as NotionDateProperty).date?.start || null : null; }, }; const extractPageMetadata = async ( pageEntry: PageObjectResponse ): Promise<NotionPageData> => { const properties = pageEntry.properties; const pageId = getPropertyValue.title( properties[CONFIG.PROPERTY_NAMES.PAGE_ID] ); // Fetch the recordMap for this page const recordMap = await notionCompatAPI.getPage(pageId || pageEntry.id); // Extract icon and cover image from recordMap let icon = ""; let imageUrl = ""; const formattedPageId = pageId ? formatPageId(pageId) : pageEntry.id; const pageBlock = recordMap.block[formattedPageId]; if (pageBlock?.value?.format) { icon = pageBlock.value.format.page_icon || ""; imageUrl = pageBlock.value.format.page_cover || ""; } return { pageId, pathname: getPropertyValue.richText( properties[CONFIG.PROPERTY_NAMES.PATHNAME] ), isPublished: getPropertyValue.checkbox( properties[CONFIG.PROPERTY_NAMES.PUBLISHED] ), title: getPropertyValue.richText(properties[CONFIG.PROPERTY_NAMES.TITLE]), description: getPropertyValue.richText( properties[CONFIG.PROPERTY_NAMES.DESCRIPTION] ), imageUrl, recordMap, parentPageId: getPropertyValue.richText( properties[CONFIG.PROPERTY_NAMES.PARENT_PAGE_ID] ), icon, lastModifiedAt: getPropertyValue.date( properties[CONFIG.PROPERTY_NAMES.LAST_MODIFIED] ), createdAt: pageEntry.created_time, updatedAt: pageEntry.last_edited_time, }; }; const storePagesInSupabase = async (pages: NotionPageData[]): Promise<void> => { const supabase = await createAdminClient(); // Use a transaction for better performance and atomicity const { error } = await supabase .from("notion_pages") .upsert(pages, { onConflict: "pageId" }); if (error) { throw new Error(`Failed to store pages in Supabase: ${error.message}`); } }; export async function GET() { try { // Query the database to get all entries const response = await notionClient.databases.query({ database_id: CONFIG.DATABASE_ID, page_size: CONFIG.PAGE_SIZE, }); if (!response.results.length) { return NextResponse.json({ success: true, message: "No pages found in the database", pages: [], }); } // Process each entry and fetch its recordMap const pagesWithRecordMaps = await Promise.all( response.results.map((entry) => extractPageMetadata(entry as PageObjectResponse) ) ); // Store the data in Supabase await storePagesInSupabase(pagesWithRecordMaps); return NextResponse.json({ success: true, pages: pagesWithRecordMaps, }); } catch (error) { console.error("Error in store-pages route:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; return NextResponse.json( { success: false, error: "Failed to fetch data from Notion database", details: errorMessage, }, { status: 500 } ); } }
📄 Step 4: The NotionPage
Component
This will render our Notion pages.
The code
// components/NotionPage.tsx "use client"; import { NotionRenderer } from "react-notion-x"; import dynamic from "next/dynamic"; import Image from "next/image"; import Link from "next/link"; import { ExtendedRecordMap } from "notion-types"; export default function NotionPage({ recordMap }: { recordMap: ExtendedRecordMap }) { const Code = dynamic(() => import("react-notion-x/build/third-party/code").then(m => m.Code)); const Collection = dynamic(() => import("react-notion-x/build/third-party/collection").then(m => m.Collection)); const Equation = dynamic(() => import("react-notion-x/build/third-party/equation").then(m => m.Equation)); const Pdf = dynamic(() => import("react-notion-x/build/third-party/pdf").then(m => m.Pdf), { ssr: false }); const Modal = dynamic(() => import("react-notion-x/build/third-party/modal").then(m => m.Modal), { ssr: false }); return ( <NotionRenderer recordMap={recordMap} components={{ nextImage: Image, nextLink: Link, Code, Collection, Equation, Modal, Pdf }} fullPage disableHeader /> ); }
🚀 Step 5: Routing in Next.js
This will render our blog posts.
The code
// app/blog/[slug]/page.tsx import NotionPage from "@/components/NotionPage"; import { createClient } from "@/utils/supabase/server"; import { notFound } from "next/navigation"; import { Metadata } from "next"; async function getNotionData(slug: string) { const supabase = await createClient(); // Query Supabase for the page with matching pathname const { data, error } = await supabase .from("notion_pages") .select("*") .eq("pathname", `/blog/${slug}`) .eq("isPublished", true) .single(); if (error) { console.error("Error fetching page:", error); return null; } return data; } export const revalidate = 3600; // Revalidate every hour export async function generateMetadata({ params, }: { params: { slug: string }; }): Promise<Metadata> { const data = await getNotionData(params.slug); if (!data) return {}; return { title: data.title, description: data.description, openGraph: { title: data.title, description: data.description, type: "article", publishedTime: data.createdAt, }, twitter: { card: "summary_large_image", title: data.title, description: data.description, }, }; } type Params = Promise<{ slug: string }>; export default async function BlogPost(props: { params: Params }) { const params = await props.params; const data = await getNotionData(params.slug); if (!data) return notFound(); return ( <main> <NotionPage recordMap={data.recordMap} /> </main> ); }
🚀 Step 6: Cron our app/api/fetch-notion-pages/route.ts
You can stop here and hit the api when you want to update. I decided to create a cron job that trigger an update every 10m.
🎁 Bonus: Blog Index Page
This will render a list of blog posts.
The code
// app/blog/page.tsx import Link from "next/link"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import { createClient } from "@/utils/supabase/server"; import { format } from "date-fns"; export default async function BlogList() { const supabase = createClient(); const { data: posts } = await supabase .from("notion_pages") .select("title, pathname, createdAt, description") .eq("isPublished", true) .order("createdAt", { ascending: false }); return ( <main className="max-w-4xl mx-auto py-8 px-4"> <h1 className="text-3xl font-bold mb-8">Blog Posts</h1> <div className="grid gap-6 md:grid-cols-2"> {posts.map(post => ( <Link href={post.pathname} key={post.pathname}> <Card className="hover:shadow-lg transition-shadow"> <CardHeader> <CardTitle>{post.title}</CardTitle> <CardDescription>{format(new Date(post.createdAt), "MMMM d, yyyy")}</CardDescription> </CardHeader> <CardContent>{post.description}</CardContent> </Card> </Link> ))} </div> </main> ); }
Total development time: ~10 hours → Now you can ship in ~1 hour.
Key takeaways:
- Page IDs differ from URL slugs; extract the raw ID.
- Supabase’s JSONB ordering may break
react-notion-x
—cache JSON in text fields or disable reordering.
notion-client
is tempting, but the official API +notion-compat
works best.
- Always patch upstream bugs or track PRs (e.g.,
[email protected]
).
William Bellity
CTO of Zeroclick.app