Blog with Notion + Next.js 15

11/09/202519 min
nextnotion

Complete Guide: Creating a Blog with Notion + Next.js 15


Table of Contents

  1. Introduction
  2. Prerequisites
  3. Creating the Next.js Project
  4. Configuring Notion
  5. Dependencies and What Each Package Does
  6. Type Definitions (TypeScript)
  7. Connecting to Notion — /lib/notion.ts
  8. Translation and Multi-language
  9. Listing Page — /blog
  10. 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:
  1. Notion Setup — prepare a database to store your posts.
  2. Next.js Integration — connect via API and organize the data.
  3. Rendering posts — using Notion blocks to display the full page content.
  4. 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:
bash
npx create-next-app@latest my-app --yes
Then, enter the project directory:
bash
cd my-blog
And run the local server:
bash
npm 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

  1. Open Notion and navigate to the page where you want to create the database.
  2. Click Add a new → select Empty Database.
  3. Rename the database to something like Blog Posts.

4.2 Table structure

Add the following columns (properties) in the database:
Column NameTypeDescription
idTextA unique identifier for the post (optional if using slug).
postPageMain field with the post content
slugTextFriendly URL for the post (e.g., how-to-integrate-notion).
titleTextPost title
tagsMulti-selectList of categories or related topics
descriptionTextShort summary of the post
dateDatePublication date
publishedCheckboxMarks if the post is published (true) or draft

4.3 Example filled database

idpostslugtitletagsdescriptiondatepublished
1How to integrate Notion and Next.jshow-to-integrate-notionIntegrating Notion with Next.jsNext.js, NotionStep-by-step tutorial2025-09-10
2Markdown in Notionmarkdown-notionUsing Markdown in NotionNotion, MarkdownRendering posts in MD2025-09-12
3Draft Postdraft-postUnpublished draftDraftJust an example draft2025-09-14

4.4 Creating the Notion integration

  1. Go to Notion Developers – My Integrations.
  2. Click + New Integration.
  3. Give it a name (e.g., Blog Integration) and select the correct workspace.
  4. Copy the generated Internal Integration Token → paste it into .env.local as:
plain
NOTION_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:
bash
npm install @notionhq/client notion-to-md react-markdown remark-gfm @tailwindcss/typography @vercel/functions

5.2 What each package does

  • @notionhq/client
    Official Notion SDK.
    Connects to the Notion API using NOTION_TOKEN and fetches data directly from your database (posts, properties, content blocks, etc.).
  • notion-to-md
    Converts Notion blocks to Markdown.
    Instead of manually rendering blocks, you can transform a page into Markdown text to use with React Markdown renderers.
  • react-markdown
    Library 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-gfm
    Plugin for react-markdown adding support for GitHub Flavored Markdown (GFM).
    Includes tables, task lists, automatic links, and other features not in plain Markdown.
  • @tailwindcss/typography
    Tailwind 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.
typescript
export 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:
plain
src/
├─ 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):

typescript
import 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):

typescript
import { 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
typescript
import { 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.
typescript
import { 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:
  1. Set up Notion integration and prepare the database with organized columns (slug, title, tags, date, published, etc.).
  2. Connect Next.js to Notion using @notionhq/client.
  3. Transform content with notion-to-md and render posts in Markdown using react-markdown + remark-gfm.
  4. Apply ready-made responsive typography styles with @tailwindcss/typography.
  5. 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.
  6. Implement filters and metadata to show only published posts with a valid date.
  7. 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