Blog com Notion + Next.js 15

11/09/202520 min
nextnotion

Guia completo: Criar um blog com Notion + Next.js 15


Índice

  1. Introdução
  2. Pré-requisitos
  3. Criando o projeto Next.js
  4. Configurando o Notion
  5. Dependências e o que cada pacote faz
  6. Definição de Tipos (TypeScript)
  7. Conectar ao Notion - /lib/notion.ts
  8. Tradução e multi-idioma
  9. Página de listagem - /blog
  10. Página de post único /blog/[slug] — renderizando blocos do Notion

1. Introdução

Por que usar Notion + Next.js?

O Notion é uma ferramenta popular de produtividade que combina notas, banco de dados e colaboração em tempo real. Além de anotações, você pode criar tabelas e usá-lo como um CMS (Content Management System) simples e flexível. Isso permite que você (ou sua equipe) gerencie posts sem precisar abrir um editor de código: basta atualizar a database no Notion, e o conteúdo estará pronto para ser exibido no blog.
Já o Next.js é um framework moderno baseado em React, ideal para criar sites de alto desempenho. No contexto de blogs, os principais benefícios são:
  • SEO otimizado: suporte nativo a metadados, rotas dinâmicas e sitemap, ajudando seu blog a ser encontrado no Google.
  • Performance: geração de páginas estáticas (SSG) e suporte a revalidação automática (ISR), garantindo carregamento rápido.
  • Flexibilidade: possibilidade de misturar conteúdo estático (posts) com conteúdo dinâmico (comentários, widgets, etc.).
  • Imagens otimizadas: tratamento automático de formatos, tamanhos e carregamento sob demanda.
Essa combinação torna o desenvolvimento de um blog simples de manter, rápido para o usuário e bem posicionado nos mecanismos de busca.

O que você vai aprender neste guia

Este guia mostra, passo a passo, como montar um blog integrado entre Notion e Next.js:
  1. Configuração no Notion — preparar uma database para armazenar seus posts.
  2. Integração com Next.js — conectar via API e organizar os dados.
  3. Renderização de posts — utilizando os blocos do Notion para exibir o conteúdo completo da página.
  4. SEO, rotas dinâmicas e boas práticas para um blog moderno e escalável.
Ao final, você terá um blog funcional, fácil de manter e pronto para crescer junto com seus projetos.

2. Pré-requisitos

Antes de começar a criar o blog, certifique-se de ter o seguinte configurado:
  • Conta no Notion com permissão para criar integrações (Internal Integration).
  • Node.js (v18 ou superior) e npm ou yarn instalados.

3. Criando o projeto Next.js

Neste capítulo, vamos criar o projeto Next.js e configurar a estrutura inicial do blog, incluindo as rotas básicas que usaremos para integrar com o Notion.

3.1 Inicializando o projeto

Se ainda não tem um projeto Next.js configurado, execute:
bash
npx create-next-app@latest my-app --yes
Depois, entre no diretório do projeto:
bash
cd my-blog
E rode o servidor local:
bash
npm run dev
# ou
yarn dev
O projeto estará disponível em http://localhost:3000.

4. Configurando o Notion

Neste capítulo, vamos criar a database no Notion que servirá como backend do nosso blog.
Cada linha da tabela representará um post do blog, e cada coluna armazenará informações específicas.

4.1 Criando a database

  1. Abra o Notion e vá até a página onde deseja criar a database.
  2. Clique em Add a new → selecione Empty Database.
  3. Renomeie a database para algo como Blog Posts.

4.2 Estrutura da tabela

Adicione as seguintes colunas (properties) na database:
Nome da colunaTipoDescrição
idTextUm identificador único para o post (opcional se usar slug).
postPageO campo principal, com o post
slugTextURL amigável para o post (exemplo: como-integrar-notion).
titleTextTítulo do post
tagsMulti-selectLista de categorias ou assuntos relacionados.
descriptionTextPequena descrição/resumo do post.
dateDateData de publicação.
publishedCheckboxDefine se o post está publicado (true) ou em rascunho.

4.3 Exemplo de database preenchida

idpostslugtitletagsdescriptiondatepublished
1Como integrar Notion e Next.jscomo-integrar-notionIntegrando Notion com Next.jsNext.js, NotionTutorial passo a passo2025-09-10
2Markdown no Notionmarkdown-notionUsando Markdown no NotionNotion, MarkdownRenderização de posts em MD2025-09-12
3Rascunho de Postrascunho-postRascunho não publicadoDraftApenas um exemplo de rascunho2025-09-14

4.4 Criando a integração com o Notion

  1. Acesse Notion Developers – My Integrations.
  2. Clique em + New Integration.
  3. Dê um nome (ex.: Blog Integration) e selecione o workspace correto.
  4. Copie o Internal Integration Token gerado → cole no .env.local como:
plain
NOTION_TOKEN=seu_token_aqui
NOTION_DATABASE_ID=seu_database_id_aqui

5. Dependências e o que cada pacote faz

Para conectar o Next.js ao Notion e renderizar os posts no formato correto, precisamos instalar algumas dependências adicionais.

5.1 Instalação

No terminal, dentro do seu projeto, rode:
bash
npm install @notionhq/client notion-to-md react-markdown remark-gfm @tailwindcss/typography @vercel/functions

5.2 O que cada pacote faz

  • @notionhq/client
    É o SDK oficial do Notion.
    Ele permite se conectar à API do Notion usando o NOTION_TOKEN e buscar dados diretamente da sua database (posts, propriedades, blocos de conteúdo etc.).

  • notion-to-md
    Converte os blocos do Notion em Markdown.
    Assim, ao invés de renderizar os blocos manualmente, podemos transformar o conteúdo de uma página em texto Markdown para usar em conjunto com renderizadores de Markdown no React.

  • react-markdown
    Biblioteca que renderiza conteúdo Markdown em componentes React.
    Combinada com o notion-to-md, permite transformar os posts escritos no Notion em HTML renderizado no blog.

  • remark-gfm
    Plugin para o react-markdown que adiciona suporte ao GitHub Flavored Markdown (GFM).
    Isso inclui tabelas, listas de tarefas , links automáticos, entre outros recursos extras que não estão no Markdown puro.

  • @tailwindcss/typography
    Plugin do Tailwind que adiciona a classe prose.
    Essa classe aplica estilos prontos e responsivos para texto rico (posts, artigos, documentação etc.), deixando o conteúdo do blog mais legível sem precisar escrever CSS manual.

  • @vercel/functions (geolocation / middleware)
    Utilizar geolocation importado de @vercel/functions é uma forma prática de detectar a localização do visitante diretamente no Edge Middleware em projetos hospedados na Vercel. Isso facilita redirecionar/rewritar para o prefixo de idioma correto (ex.: /pt ou /en) sem chamadas externas.
    O que faz
    • Fornece utilitários para environments Edge da Vercel, incluindo geolocation(request) que retorna dados de país/continente do visitante.
    • Ideal para lógica de roteamento/rewrites em middleware.ts (runtime Edge).

6. Definição de Tipos (TypeScript)

Antes de conectar ao Notion, é importante definir interfaces e tipos no TypeScript.
Isso traz segurança ao desenvolvimento, facilita o autocomplete no editor e evita erros quando lidamos com os dados brutos da API do Notion.
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. Conectar ao Notion — src/lib/notion.ts

Coloque este arquivo em: src/lib/notion.ts
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;

  // monta filtros
  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;

  // markdown
  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,
  };
}

O que o arquivo faz (explicação por partes)

Inicialização

  • new Client({ auth: process.env.NOTION_TOKEN }) — cria a instância do SDK oficial do Notion, usando o token da integração.
  • new NotionToMarkdown({ notionClient: notion }) — cria o conversor que transforma blocos Notion em Markdown.

queryAllDataSource(...)

  • Responsável por consultar todos os items de um data source da database.
  • Faz paginação com start_cursor/next_cursor até trazer todos os resultados.
  • Monta um filter combinando regras em AND:
    • Published = true (apenas posts marcados como publicados).
    • Date <= today (só mostra posts cuja data é hoje ou anterior).
    • Opcionalmente filtra por Tags — aceita string ou string [ ] e assume que a property Tags é multi-select (usa multi_select.contains).
    • Opcionalmente filtra por Lang — aceita string ou string[ ] e assume que a property Lang é select (usa select.equals);
  • Retorna a lista de páginas (raw objects) do Notion.

listPosts({ tags })

  • Recupera a database com notion.databases.retrieve(...).
  • Lê o data_sources para obter o dataSourceId usado por queryAllDataSource.
  • Chama queryAllDataSource para buscar as páginas.
  • Mapeia cada página Notion para um formato mais simples (NotionPostFomat), extraindo:
    • title, slug, date, tags, description, published, id.
  • Ideal para usar na listagem de posts (/blog).

getPostMarkdownBySlug(slug, lang?

  • Consulta a página no Notion filtrando por Slug e, se informado, também por Lang.
  • Obtém o pageId e converte o conteúdo em Markdown com o notion-to-md:
    • pageToMarkdown(pageId) → gera blocos intermediários.
    • toMarkdownString(...) → transforma em objeto/string Markdown.
  • Monta a string final de markdown juntando os blocos (parent + children).
  • Retorna também metadados: id, title, date, tags, description, mdObject, lang.
  • Uso principal: página individual /blog/[slug]*, onde o markdown é renderizado pelo react-markdown.

8. Tradução e multi-idioma

Este capítulo mostra como estruturar o projeto para suportar múltiplos idiomas usando um segmento de rota dinâmico [ lang ] na raiz (/pt/... e /en/...), um middleware que gerencia redirecionamento/rewrites e um pequeno sistema de dicionários (en.json, pt.json) para textos da UI.

8.1 Estrutura de pastas recomendada

Coloque as rotas do blog dentro do segmento de idioma:
src/
├─ app/
│ ├─ [lang]/
│ │ ├─ layout.tsx
│ │ ├─ page.tsx # home por idioma (opcional)
│ │ └─ blog/
│ │ │ ├─ page.tsx # listagem de posts (por idioma)
│ │ └─ └─ [slug]/
│ │ └─ page.tsx # post individual (por idioma)
│ ├─ dictionary
│ │ ├─ en.json
│ │ └─ pt.json
│ └─ ultis
code
            └─ getDictionary.ts

8.2 Um utilitário simples (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 (coloque em middleware.ts na raiz do projeto):

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 Exemplo de Dicionários

/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. Página /blog — listagem de posts

Crie src/app/[lang]/blog/page.tsx como um Server Component async que chama listPosts().
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>
  );
}
Exemplo simples de 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. Página blog/[slug]/page.tsx — post único

Arquivo: src/app/[lang]/blog/[slug]/page.tsx
Este arquivo é a página de post individual. Ele busca o conteúdo no Notion (via getPostMarkdownBySlug), gera metadados (title/description) dinamicamente e renderiza o Markdown com suporte a GFM e blocos de código estilizados.
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>
  );
}


📌 Conclusão

Neste guia, você aprendeu como:
  1. Configurar a integração com o Notion e preparar a database com colunas organizadas (slug, título, tags, data, publicado, etc.).
  2. Conectar o Next.js ao Notion utilizando o @notionhq/client.
  3. Transformar conteúdo com notion-to-md e renderizar posts em Markdown no front usando react-markdown + remark-gfm.
  4. Aplicar estilos de tipografia prontos e responsivos com o @tailwindcss/typography.
  5. Construir as páginas do blog:
    • /blog → listagem dos posts com metadados, tags e paginação.
    • /blog/[slug] → página individual de post, já com título, data, tempo de leitura, tags e conteúdo renderizado.
  6. Implementar filtros e metadados para garantir que apenas posts publicados e com data válida sejam exibidos.
  7. Organizar o fluxo de tradução para suportar múltiplos idiomas.


🙏 Agradecimento

Obrigado por acompanhar este guia até o final! 🎉
Espero que ele tenha ajudado você a entender não só o passo a passo da integração, mas também as boas práticas de organização e manutenção de um blog moderno usando Notion + Next.js.
Bons estudos e bom código! 💻✨
Ygor Mendanha - YM Desenvolvimento