Guia completo: Criar um blog com Notion + Next.js 15
Índice
- Introdução
- Pré-requisitos
- Criando o projeto Next.js
- Configurando o Notion
- Dependências e o que cada pacote faz
- Definição de Tipos (TypeScript)
- Conectar ao Notion - /lib/notion.ts
- Tradução e multi-idioma
- Página de listagem - /blog
- 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:
- Configuração no Notion — preparar uma database para armazenar seus posts.
- Integração com Next.js — conectar via API e organizar os dados.
- Renderização de posts — utilizando os blocos do Notion para exibir o conteúdo completo da página.
- 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:
bashnpx create-next-app@latest my-app --yes
Depois, entre no diretório do projeto:
bashcd my-blog
E rode o servidor local:
bashnpm 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
- Abra o Notion e vá até a página onde deseja criar a database.
- Clique em Add a new → selecione Empty Database.
- Renomeie a database para algo como Blog Posts.
4.2 Estrutura da tabela
Adicione as seguintes colunas (properties) na database:
| Nome da coluna | Tipo | Descrição |
|---|---|---|
| id | Text | Um identificador único para o post (opcional se usar slug). |
| post | Page | O campo principal, com o post |
| slug | Text | URL amigável para o post (exemplo: como-integrar-notion). |
| title | Text | Título do post |
| tags | Multi-select | Lista de categorias ou assuntos relacionados. |
| description | Text | Pequena descrição/resumo do post. |
| date | Date | Data de publicação. |
| published | Checkbox | Define se o post está publicado (true) ou em rascunho. |
4.3 Exemplo de database preenchida
| id | post | slug | title | tags | description | date | published |
|---|---|---|---|---|---|---|---|
| 1 | Como integrar Notion e Next.js | como-integrar-notion | Integrando Notion com Next.js | Next.js, Notion | Tutorial passo a passo | 2025-09-10 | ✅ |
| 2 | Markdown no Notion | markdown-notion | Usando Markdown no Notion | Notion, Markdown | Renderização de posts em MD | 2025-09-12 | ✅ |
| 3 | Rascunho de Post | rascunho-post | Rascunho não publicado | Draft | Apenas um exemplo de rascunho | 2025-09-14 | ❌ |
4.4 Criando a integração com o Notion
- Acesse Notion Developers – My Integrations.
- Clique em + New Integration.
- Dê um nome (ex.: Blog Integration) e selecione o workspace correto.
- Copie o Internal Integration Token gerado → cole no .env.local como:
plainNOTION_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:
bashnpm 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-mdConverte 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-markdownBiblioteca 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-gfmPlugin 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/typographyPlugin 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.
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. 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):
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 (coloque em middleware.ts na raiz do projeto):
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 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:
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. 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.
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> ); }
📌 Conclusão
Neste guia, você aprendeu como:
- Configurar a integração com o Notion e preparar a database com colunas organizadas (slug, título, tags, data, publicado, etc.).
- Conectar o Next.js ao Notion utilizando o @notionhq/client.
- Transformar conteúdo com notion-to-md e renderizar posts em Markdown no front usando react-markdown + remark-gfm.
- Aplicar estilos de tipografia prontos e responsivos com o @tailwindcss/typography.
- 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.
- Implementar filtros e metadados para garantir que apenas posts publicados e com data válida sejam exibidos.
- 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