Complete Guide: Creating a Blog with Notion + Next.js 15
Table of Contents
- Introduction
- Prerequisites
- Creating the Next.js Project
- Configuring Notion
- Dependencies and What Each Package Does
- Type Definitions (TypeScript)
- Connecting to Notion — /lib/notion.ts
- Translation and Multi-language
- Listing Page — /blog
- Single Post Page — /blog/[slug] — Rendering Notion Blocks
1. Introduction
Why use Notion + Next.js?
Notion is a popular productivity tool that combines notes, databases, and real-time collaboration. Beyond note-taking, you can create tables and use it as a simple and flexible CMS (Content Management System). This allows you (or your team) to manage posts without opening a code editor: just update the database in Notion, and the content will be ready to display on the blog.
Next.js is a modern React-based framework ideal for building high-performance websites. For blogs, the main benefits are:
- SEO optimized: native support for metadata, dynamic routes, and sitemaps, helping your blog rank on Google.
- Performance: static site generation (SSG) and incremental static regeneration (ISR) ensure fast loading.
- Flexibility: mix static content (posts) with dynamic content (comments, widgets, etc.).
- Optimized images: automatic handling of formats, sizes, and on-demand loading.
This combination makes building a blog easy to maintain, fast for users, and well-positioned in search engines.
What you will learn in this guide
This guide shows step by step how to create a blog integrated with Notion and Next.js:
- Notion Setup — prepare a database to store your posts.
- Next.js Integration — connect via API and organize the data.
- Rendering posts — using Notion blocks to display the full page content.
- SEO, dynamic routes, and best practices for a modern and scalable blog.
By the end, you will have a fully functional blog that is easy to maintain and ready to grow with your projects.
2. Prerequisites
Before starting to create the blog, make sure you have the following set up:
- Notion account with permission to create integrations (Internal Integration).
- Node.js (v18 or higher) and npm or yarn installed.
3. Creating the Next.js Project
In this chapter, we create the Next.js project and configure the initial blog structure, including the basic routes we will use to integrate with Notion.
3.1 Initializing the project
If you don’t have a Next.js project set up yet, run:
bashnpx create-next-app@latest my-app --yes
Then, enter the project directory:
bashcd my-blog
And run the local server:
bashnpm run dev # ou yarn dev
The project will be available at http://localhost:3000.
4. Configuring Notion
In this chapter, we create the database in Notion that will serve as the backend for our blog.
Each row in the table represents a blog post, and each column stores specific information.
4.1 Creating the database
- Open Notion and navigate to the page where you want to create the database.
- Click Add a new → select Empty Database.
- Rename the database to something like Blog Posts.
4.2 Table structure
Add the following columns (properties) in the database:
| Column Name | Type | Description |
|---|---|---|
| id | Text | A unique identifier for the post (optional if using slug). |
| post | Page | Main field with the post content |
| slug | Text | Friendly URL for the post (e.g., how-to-integrate-notion). |
| title | Text | Post title |
| tags | Multi-select | List of categories or related topics |
| description | Text | Short summary of the post |
| date | Date | Publication date |
| published | Checkbox | Marks if the post is published (true) or draft |
4.3 Example filled database
| id | post | slug | title | tags | description | date | published |
|---|---|---|---|---|---|---|---|
| 1 | How to integrate Notion and Next.js | how-to-integrate-notion | Integrating Notion with Next.js | Next.js, Notion | Step-by-step tutorial | 2025-09-10 | ✅ |
| 2 | Markdown in Notion | markdown-notion | Using Markdown in Notion | Notion, Markdown | Rendering posts in MD | 2025-09-12 | ✅ |
| 3 | Draft Post | draft-post | Unpublished draft | Draft | Just an example draft | 2025-09-14 | ❌ |
4.4 Creating the Notion integration
- Go to Notion Developers – My Integrations.
- Click + New Integration.
- Give it a name (e.g., Blog Integration) and select the correct workspace.
- Copy the generated Internal Integration Token → paste it into .env.local as:
plainNOTION_TOKEN=your_token_here NOTION_DATABASE_ID=your_database_id_here
5. Dependencies and What Each Package Does
To connect Next.js to Notion and render posts correctly, we need some additional dependencies.
5.1 Installation
In your terminal, inside the project, run:
bashnpm install @notionhq/client notion-to-md react-markdown remark-gfm @tailwindcss/typography @vercel/functions
5.2 What each package does
-
@notionhq/clientOfficial Notion SDK.Connects to the Notion API using NOTION_TOKEN and fetches data directly from your database (posts, properties, content blocks, etc.).
-
notion-to-mdConverts Notion blocks to Markdown.Instead of manually rendering blocks, you can transform a page into Markdown text to use with React Markdown renderers.
-
react-markdownLibrary that renders Markdown content as React components.Combined with notion-to-md, it transforms posts written in Notion into HTML rendered on the blog.
-
remark-gfmPlugin for react-markdown adding support for GitHub Flavored Markdown (GFM).Includes tables, task lists, automatic links, and other features not in plain Markdown.
-
@tailwindcss/typographyTailwind plugin that adds the prose class.Provides ready-to-use responsive styles for rich text (posts, articles, documentation), making content readable without writing custom CSS.
-
@vercel/functions (geolocation / middleware)Provides utilities to detect the visitor's location directly in Edge Middleware for Vercel-hosted projects.Useful for language-specific redirects (e.g., /pt or /en) without external calls.
6. Type Definitions (TypeScript)
Before connecting to Notion, define interfaces and types in TypeScript.
This ensures development safety, editor autocomplete, and prevents errors when handling raw Notion API data.
typescriptexport interface NotionPostFomat { id: string; title: string; slug: string; date: Date | null; published: boolean; tags: Array<{ name: string; color: ColorFortags }>; description: string[]; } export interface TagsForBlog { name: string; color: ColorFortags; } export type ColorFortags = "red" | "pink"; export interface NotionBlog { object?: string; id: string; created_time?: Date; last_edited_time?: Date; created_by?: TedBy; last_edited_by?: TedBy; cover?: null; icon?: null; parent?: Parent; archived?: boolean; in_trash?: boolean; is_locked?: boolean; properties?: Properties; url?: string; public_url?: null; } interface TedBy { object?: string; id?: string; } interface Parent { type?: string; data_source_id?: string; database_id?: string; } interface Properties { ID?: ID; Description?: Description; Published?: Published; Title?: Description; Slug?: Description; Date?: DateClass; Tags?: Tag; Post?: Post; } interface DateClass { id?: string; type?: string; date?: DateDate; } interface DateDate { start?: Date; end?: null; time_zone?: null; } interface Description { id?: string; type?: string; rich_text?: RichText[]; } interface RichText { type?: string; text?: Text; annotations?: Annotations; plain_text?: string; href?: null; } interface Annotations { bold?: boolean; italic?: boolean; strikethrough?: boolean; underline?: boolean; code?: boolean; color?: string; } interface Text { content?: string; link?: null; } interface ID { id?: string; type?: string; unique_id?: UniqueID; } interface UniqueID { prefix?: null; number?: number; } interface Post { id?: string; type?: string; title?: RichText[]; } interface Published { id?: string; type?: string; checkbox?: boolean; } interface Tag { id?: string; type?: string; multi_select?: MultiSelect[]; } interface MultiSelect { id?: string; name?: string; color?: ColorFortags; }
7. Connecting to Notion — src/lib/notion.ts
This file connects to Notion, queries databases, and converts page content to Markdown.
typescript// src/lib/notion.ts import { NotionBlog, NotionPostFomat, TagsForBlog } from "@/types/notion"; import { Client } from "@notionhq/client"; import { NotionToMarkdown } from "notion-to-md"; const notion = new Client({ auth: process.env.NOTION_TOKEN }); const n2m = new NotionToMarkdown({ notionClient: notion }); async function queryAllDataSource({ dataSourceId, tags, lang, }: { dataSourceId: string; tags?: string | string[]; lang?: string | string[]; }) { const tagPropertyName = "Tags"; const langPropertyName = "Lang"; const datePropertyName = "Date"; const notionAny = notion as any; const all: NotionBlog[] = []; let cursor: string | undefined = undefined; const today = new Date().toISOString().split("T")[0]; const baseFilter = { and: [ { property: "Published", checkbox: { equals: true } }, { property: datePropertyName, date: { on_or_before: today } }, ], }; let tagFilter: any | undefined; if (tags) { if (Array.isArray(tags)) { tagFilter = { or: tags.map((t) => ({ property: tagPropertyName, multi_select: { contains: String(t) }, })), }; } else { tagFilter = { property: tagPropertyName, multi_select: { contains: String(tags) }, }; } } let langFilter: any | undefined; if (lang) { if (Array.isArray(lang)) { langFilter = { or: lang.map((l) => ({ property: langPropertyName, select: { equals: String(l) }, })), }; } else { langFilter = { property: langPropertyName, select: { equals: String(lang) }, }; } } const combinedAnd: any[] = [baseFilter]; if (tagFilter) combinedAnd.push(tagFilter); if (langFilter) combinedAnd.push(langFilter); const combinedFilter = combinedAnd.length === 1 ? baseFilter : { and: combinedAnd }; do { const res: any = await notionAny.dataSources.query({ data_source_id: dataSourceId, start_cursor: cursor, page_size: 50, filter: combinedFilter, }); all.push(...(res.results ?? [])); cursor = res.has_more ? res.next_cursor : undefined; } while (cursor); return all; } export async function listPosts({ tags, }: { tags?: string; }): Promise<NotionPostFomat[]> { const db: any = await notion.databases.retrieve({ database_id: process.env.DATABASE_ID!, }); const dataSources = db.data_sources ?? db.dataSources ?? []; if (!Array.isArray(dataSources) || dataSources.length === 0) { throw new Error( "Database não expõe data_sources — verifique a database no Notion" ); } const dsId = dataSources[0].id as string; const pages = await queryAllDataSource({ dataSourceId: dsId, tags }); return pages.map((p) => { const props = p.properties ?? {}; const title = props.Title?.rich_text ?.map((item) => item.plain_text) .join(""); const slug = props.Slug?.rich_text?.map((item) => item.plain_text).join(""); const date = props.Date?.date?.start ?? null; const tagsProp = props.Tags; const tags: Array<TagsForBlog> = []; if (tagsProp?.multi_select && tagsProp.multi_select?.length > 0) { tagsProp.multi_select?.map((opt) => tags.push({ name: opt.name ?? "-", color: opt.color ?? "red", }) ); } const descriptionProp = props.Description; const description: Array<string> = []; if (descriptionProp?.rich_text && descriptionProp.rich_text?.length > 0) { descriptionProp.rich_text.map((opt) => description.push(opt.text?.content ?? "") ); } const published = typeof props.Published?.checkbox === "boolean" ? props.Published.checkbox : false; return { id: p.id, title: String(title), slug: String(slug), date, published, tags, description, }; }); } export async function getPostMarkdownBySlug(slug: string, lang?: string) { if (!slug) throw new Error("slug é obrigatório"); const db: any = await notion.databases.retrieve({ database_id: process.env.DATABASE_ID!, } as any); const dataSources = db.data_sources ?? []; if (!dataSources.length) throw new Error("Database não tem data_sources"); const dataSourceId = dataSources[0].id; const filters: any[] = [{ property: "Slug", rich_text: { equals: slug } }]; if (lang) { filters.push({ property: "Lang", select: { equals: lang }, }); } const res: any = await notion.dataSources.query({ data_source_id: dataSourceId, filter: filters.length === 1 ? filters[0] : { and: filters }, }); if (!res.results || res.results.length === 0) { throw new Error( `Nenhum post encontrado com slug "${slug}"${ lang ? ` e lang "${lang}"` : "" }` ); } const page: NotionBlog = res.results[0]; const pageId = page.id; const mdBlocks = await n2m.pageToMarkdown(pageId); const mdObj = n2m.toMarkdownString(mdBlocks); let markdown = ""; if (typeof mdObj === "string") { markdown = mdObj; } else if (mdObj && typeof mdObj === "object") { if (mdObj.parent && typeof mdObj.parent === "string") { markdown += mdObj.parent; } const children = mdObj.children ?? []; if (Array.isArray(children) && children.length > 0) { for (const c of children) { if (c?.parent && typeof c.parent === "string") { markdown += `\n\n---\n\n${c.parent}`; } } } } const props = page.properties ?? {}; const title = props.Title?.rich_text?.map((item) => item.plain_text).join(""); const date = props.Date?.date?.start ?? null; const tagsProp = props.Tags; const tags: Array<TagsForBlog> = []; if (tagsProp?.multi_select && tagsProp.multi_select?.length > 0) { tagsProp.multi_select?.map((opt) => tags.push({ name: opt.name ?? "-", color: opt.color ?? "red", }) ); } const descriptionProp = props.Description; const description: Array<string> = []; if (descriptionProp?.rich_text && descriptionProp.rich_text?.length > 0) { descriptionProp.rich_text.map((opt) => description.push(opt.text?.content ?? "") ); } return { id: pageId, slug, markdown, mdObject: mdObj, title, date, tags, description, lang, }; }
What the file does (part-by-part explanation)
Initialization
- new Client({ auth: process.env.NOTION_TOKEN }) — creates an instance of the official Notion SDK using the integration token.
- new NotionToMarkdown({ notionClient: notion }) — creates the converter that transforms Notion blocks into Markdown.
queryAllDataSource(...)
- Responsible for querying all items of a database data source.
- Paginates using start_cursor/next_cursor until all results are retrieved.
- Builds a filter combining rules with AND:
- Published = true (only posts marked as published).
- Date <= today (only shows posts whose date is today or earlier).
- Optionally filters by Tags — accepts string or string[] and assumes the Tags property is multi-select (uses multi_select.contains).
- Optionally filters by Lang — accepts string or string[] and assumes the Lang property is select (uses select.equals).
- Returns a list of pages (raw objects) from Notion.
listPosts({ tags })
- Retrieves the database with notion.databases.retrieve(...).
- Reads the data_sources to get the dataSourceId used by queryAllDataSource.
- Calls queryAllDataSource to fetch the pages.
- Maps each Notion page to a simpler format (NotionPostFomat), extracting:
- title, slug, date, tags, description, published, id.
- Ideal for use in the posts listing page (* /blog *).
getPostMarkdownBySlug(slug, lang?)
- Queries the page in Notion filtering by Slug and, if provided, also by Lang.
- Gets the pageId and converts the content into Markdown using notion-to-md:
- pageToMarkdown(pageId) → generates intermediate blocks.
- toMarkdownString(...) → transforms into a Markdown object/string.
- Builds the final markdown string by combining the blocks (parent + children).
- Also returns metadata: id, title, date, tags, description, mdObject, lang.
- Main usage: individual post page /blog/[slug], where the markdown is rendered by react-markdown.
8. Translation and Multi-language
This chapter shows how to structure the project to support multiple languages using a dynamic route segment [lang] at the root (/pt/... and /en/...), a middleware for redirects/rewrites, and a simple dictionary system (en.json, pt.json) for UI texts.
8.1 Recommended folder structure
Place blog routes inside the language segment:
plainsrc/ ├─ app/ │ ├─ [lang]/ │ │ ├─ layout.tsx │ │ ├─ page.tsx # Home per language (optional) │ │ └─ blog/ │ │ ├─ page.tsx # Blog listing (per language) │ │ └─ [slug]/ │ │ └─ page.tsx # Single post (per language) │ ├─ dictionary/ │ │ ├─ en.json │ │ └─ pt.json │ └─ utils/ │ └─ getDictionary.ts
8.2 A simple utility (getDictionary):
typescriptimport en from "@/dictionary/en.json"; import pt from "@/dictionary/pt.json"; export const getDictionary = (locale: string) => { if (locale === "pt") return pt; return en; };
8.3 Middleware (place in middleware.ts at the project root):
typescriptimport { geolocation } from "@vercel/functions"; import { NextRequest, NextResponse } from "next/server"; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const cookieLocale = request.cookies.get("locale")?.value; const cookieLang = request.cookies.get("lang")?.value; const hasBRPrefix = pathname.startsWith("/pt"); const hasUSAPrefix = pathname.startsWith("/en"); if (!hasBRPrefix && !hasUSAPrefix && !cookieLocale) { const { country = "BR" } = geolocation(request); const isBrazil = country === "BR"; if (isBrazil) { const newUrl = request.nextUrl.clone(); newUrl.pathname = `/pt${pathname}`; const response = NextResponse.rewrite(newUrl); response.cookies.set("locale", "BR"); response.cookies.set("lang", "pt"); response.cookies.set("pathname", pathname); return response; } const newUrl = request.nextUrl.clone(); newUrl.pathname = `/en${pathname}`; const response = NextResponse.redirect(newUrl); response.cookies.set("locale", "USA"); response.cookies.set("lang", "en"); response.cookies.set("pathname", pathname); return response; } if (!hasBRPrefix && !hasUSAPrefix && cookieLocale) { if (cookieLocale === "BR") { const newUrl = request.nextUrl.clone(); newUrl.pathname = `/pt${pathname}`; const response = NextResponse.rewrite(newUrl); response.cookies.set("pathname", pathname); !cookieLang && response.cookies.set("lang", "pt"); return response; } const newUrl = request.nextUrl.clone(); newUrl.pathname = `/en${pathname}`; const response = NextResponse.redirect(newUrl); response.cookies.set("pathname", pathname); !cookieLang && response.cookies.set("lang", "en"); return response; } if (hasBRPrefix) { const newUrl = request.nextUrl.clone(); newUrl.pathname = pathname; const response = NextResponse.rewrite(newUrl); response.cookies.set("locale", "BR"); response.cookies.set("lang", "pt"); response.cookies.set("pathname", pathname); return response; } if (hasUSAPrefix) { const newPathname = pathname || "/"; const newUrl = request.nextUrl.clone(); newUrl.pathname = newPathname; const response = NextResponse.rewrite(newUrl); response.cookies.set("locale", "USA"); response.cookies.set("lang", "en"); response.cookies.set("pathname", pathname); return response; } return NextResponse.next(); } export const config = { matcher: [ "/((?!api|_next/static|_next/image|favicon.ico|sitemap\\.xml$|sitemap-.*\\.xml$|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)", ], };
8.3 Example of Dictionaries
/pt.json
json{ "subTitleBlog": "Um registro do processo de criação dos meus projetos." }
/en.json
json{ "subTitleBlog": "A record of the creation process of my projects." }
9. Blog Listing Page — /blog
Server Component calling listPosts() to display all posts with metadata, tags, and pagination.
typescript// src/app/blog/page.tsx import { RenderGrid } from "@/components/blog/card"; import { listPosts } from "@/lib/notion"; import { getDictionary } from "@/utils/functions/getDictionary"; import { Metadata } from "next"; import { cookies } from "next/headers"; export async function generateMetadata(): Promise<Metadata> { const cookieStore = await cookies(); const pathname = cookieStore.get("pathname"); const path = pathname?.value || "/"; const lang = path.startsWith("/en") ? "en" : "pt"; const dict = getDictionary(lang ?? "pt"); return { title: "Blog", description: dict.subTitleBlog, }; } export default async function blogPage({ params, }: { params: Promise<{ lang: "en" | "pt" }>; }) { const { lang } = await params; const list = await listPosts({ lang }); const dict = getDictionary(lang ?? "pt"); return ( <main className="mx-auto container px-4 py-8 min-h-[calc(100vh-70px)]"> <div className="flex flex-col h-60 items-center justify-center text-center mb-4"> <h1 className="text-5xl font-bold text-white mb-2">Blog</h1> <p className="text-sm text-gray-300 max-w-2xl">{dict.subTitleBlog}</p> </div> <section className="mb-12 overflow-auto pt-5"> <RenderGrid posts={list} /> </section> </main> ); }
Simple Example of src/components/RenderGrid.tsx
typescriptimport { NotionPostFomat } from "@/types/notion"; import { getDictionary } from "@/utils/functions/getDictionary"; import dayjs from "dayjs"; import Link from "next/link"; export const colorMap = { red: "bg-red-700", pink: "bg-pink-700", }; export const RenderGrid = ({ posts, lang, }: { posts: NotionPostFomat[]; lang: "pt" | "en"; }) => { const dict = getDictionary(lang ?? "pt"); if (!posts || posts.length === 0) { return ( <p className="text-center text-sm text-muted-foreground"> {dict.noPostsFound} </p> ); } return ( <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> {posts.map((p) => { const date = dayjs(p.date).format("DD-MM-YY"); return ( <article key={p.id} className="group bg-white/5 hover:bg-white/10 rounded-2xl p-6 shadow-sm hover:shadow-lg transform-gpu hover:-translate-y-1 transition-all duration-200 border border-gray-800" > <Link href={`/blog/${p.slug}`} className="no-underline cursor-pointer" > <h3 className="text-lg font-semibold mb-2 text-light-gray group-hover:text-white"> {p.title || "Sem título"} </h3> <div className="text-xs text-gray-400 mb-3">{date}</div> <p className="text-sm text-gray-300 mb-4">{p.description}</p> <div className="flex gap-2 my-2"> {p.tags.map((tag) => ( <div className={`${ colorMap[tag.color] } rounded-xl py-1 px-2 bg-opacity-50`} key={tag.name} > <p className="relative">{tag.name}</p> </div> ))} </div> <div className="flex items-center justify-between text-sm mt-auto"> <span className="text-cyan-light font-medium">{dict.readPost}</span> <span className="text-light-gray group-hover:text-cyan-light"> → </span> </div> </Link> </article> ); })} </div> ); };
10. Single Post Page — /blog/[slug]/
Fetches the post content via getPostMarkdownBySlug, generates metadata, and renders Markdown with GFM and styled code blocks.
typescriptimport { colorMap } from "@/components/blog/card"; import { getPostMarkdownBySlug } from "@/lib/notion"; import dayjs from "dayjs"; import { Metadata } from "next"; import { cookies } from "next/headers"; import React from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; export async function generateMetadata({ params, }: { params: { slug: string }; }): Promise<Metadata> { const { slug } = params; const post = await getPostMarkdownBySlug(slug); const cookieStore = await cookies(); const pathname = cookieStore.get("pathname"); const path = pathname?.value || "/"; const description = post.description.join(" "); return { title: post.title ?? "Post", description: description || undefined, openGraph: { title: post.title ?? "Post", description: description || undefined, url: path, }, robots: { index: true, follow: true, }, }; } function CodeBlock({ className, children, }: { inline?: boolean; className?: string; children: React.ReactNode; }) { const language = className ? className.replace(/language-/, "") : ""; const codeString = String(children).replace(/\n$/, ""); return ( <div className="my-6"> <div className="flex items-center justify-between rounded-t-xl bg-gray-900/60 px-3 py-1 text-xs text-gray-300"> <span className="font-medium">{language || "code"}</span> <span className="text-gray-400 text-xs"></span> </div> <pre className="overflow-x-auto rounded-b-xl bg-[#353c44] p-4 text-sm leading-6"> <code className={`language-${language} block whitespace-pre`}> {codeString} </code> </pre> </div> ); } export default async function blogPage({ params, }: { params: Promise<{ slug: string; lang: "en" | "pt" }>; }) { const { slug, lang } = await params; const post = await getPostMarkdownBySlug(slug, lang); if (!post) { return notFound(); } const post = await getPostMarkdownBySlug(slug); const date = dayjs(post.date).format("DD/MM/YYYY"); const tags = post?.tags ?? []; function readingTime(text: string) { const words = text ? text.trim().split(/\s+/).length : 0; const minutes = Math.max(1, Math.ceil(words / 200)); return `${minutes} min`; } const time = readingTime(post?.markdown ?? ""); return ( <main className="mx-auto container px-4 py-8 min-h-[calc(100vh-70px)]"> <div className="flex flex-col h-60 items-center justify-center text-center mb-8"> <h1 className="text-4xl md:text-5xl font-bold text-white mb-3"> {post.title} </h1> <div className="flex gap-3 items-center text-sm text-gray-400"> <span>{date}</span> <span className="text-gray-600">•</span> <span>{time}</span> </div> <div className="flex gap-2 mt-4 flex-wrap justify-center"> {tags.map((tag) => ( <span key={tag.name} className={`rounded-xl py-1 px-3 text-sm bg-white/3 ${ colorMap[tag.color ?? "red"] ?? "bg-gray-700" } bg-opacity-50`} > {tag.name} </span> ))} </div> </div> <article className="prose prose-invert prose-headings:font-semibold max-w-none lg:prose-lg"> <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code: ({ className, children }) => ( <CodeBlock className={className}> {children as React.ReactNode} </CodeBlock> ), p: ({ children }) => <div>{children}</div>, div: ({ children }) => <div>{children}</div>, }} > {post.markdown} </ReactMarkdown> </article> </main> ); }
📌 Conclusion
In this guide, you learned how to:
- Set up Notion integration and prepare the database with organized columns (slug, title, tags, date, published, etc.).
- Connect Next.js to Notion using @notionhq/client.
- Transform content with notion-to-md and render posts in Markdown using react-markdown + remark-gfm.
- Apply ready-made responsive typography styles with @tailwindcss/typography.
- Build blog pages:
- /blog → post listing with metadata, tags, and pagination.
- /blog/[slug] → single post page with title, date, reading time, tags, and rendered content.
- Implement filters and metadata to show only published posts with a valid date.
- Organize translation flow to support multiple languages.
🚀 What You Have Now
A dynamic, multilingual blog fully integrated with Notion, where all content is maintained in your workspace but delivered to users with performance and style via Next.js.
🙏 Acknowledgement
Thank you for following this guide to the end! 🎉
I hope it helped you understand not only the step-by-step integration but also best practices for organizing and maintaining a modern blog using Notion + Next.js.
Happy learning and happy coding! 💻✨
Ygor Mendanha - YM Development