11 min de leitura
•18 Jun, 2025Adeus, Markdown. Olá, Notion!
Este site que você está acessando agora foi desenvolvido usando o Notion de CMS, eu curti a experiência e gostaria de compartilhar o processo de implementação para que vocês também pudessem experimentar, achei muito mais prático do que usar Markdown, etc.
Por que usar Notion como CMS?
- Interface amigável: Escreva conteúdo em uma interface visual e intuitiva
- Colaboração: Múltiplos autores podem trabalhar no mesmo conteúdo
- Estrutura flexível: Organize conteúdo com databases, tags, status e metadados
- API oficial: Acesso programático a todos os dados
- Sem custos de hospedagem: Notion cuida da infraestrutura do CMS
Pré-requisitos
- Node.js 18+
- Next.js 15
- TypeScript
- Uma conta no Notion
- Conhecimentos básicos de React e Next.js
Criando a integração
- Acesse https://www.notion.so/my-integrations
- Clique em "New integration"
- Configure o nome e workspace
- Anote o Internal Integration Token
Estruturando o database
Crie um database no Notion com as seguintes propriedades:
- Name (Title): Título interno do post
- Title (Rich Text): Título público do post
- Url (Rich Text): Slug da URL
- Description (Rich Text): Descrição/resumo
- Tags (Multi-select): Categorias do post
- Status (Select): Draft, Live, Archived
- Created (Created time): Data de criação automática
- Author (People): Autor do post
- Cover (Files & media): Imagem de capa
Compartilhando com a integração
- No seu database, clique nos três pontinhos e procure por "Connections"
- Adicione sua integração criada anteriormente
- Anote o Database ID da URL (notion.so/copie-essa-parte?v=ignore-essa)
2. Instalação das dependências
npm install @notionhq/client
npm install @notion-render/client @notion-render/hljs-plugin @notion-render/bookmark-plugin
npm install highlight.js
3. Configuração do ambiente
Crie um arquivo .env.local:
NOTION_TOKEN=your_integration_token_here
NOTION_DATABASE_ID_POSTS=your_database_id_here
4. Cliente do Notion
Crie src/shared/lib/notion.ts:
import { Client } from '@notionhq/client'
export const NotionClient = new Client({
auth: process.env.NOTION_TOKEN,
})
5. Tipagem TypeScript
Defina os tipos em src/types/notion.ts:
import {
PageObjectResponse,
RichTextItemResponse,
UserObjectResponse,
} from '@notionhq/client/build/src/api-endpoints'
export type NotionPropertyValue = {
Name: { title: RichTextItemResponse[] }
Title: { rich_text: RichTextItemResponse[] }
Url: { rich_text: RichTextItemResponse[] }
Description: { rich_text: RichTextItemResponse[] }
Tags: { multi_select: { name: string }[] }
Created: { created_time: string }
Status?: { select: { name: string } }
Author: { people: UserObjectResponse[]; id: string }
Cover?: { external?: { url: string }; file?: { url: string } }
}
export interface NotionPage extends Omit<PageObjectResponse, 'properties'> {
properties: NotionPropertyValue
}
6. Utilitários auxiliares
Extração de texto
Crie src/shared/utils/extract-text.ts:
import { RichTextItemResponse } from '@notionhq/client/build/src/api-endpoints'
type PropertyValue =
| { title?: RichTextItemResponse[] }
| { rich_text?: RichTextItemResponse[] }
| string
| undefined
export function extractText(property: PropertyValue): string {
if (!property) return ''
if (typeof property === 'string') return property
if ('rich_text' in property && Array.isArray(property.rich_text)) {
return property.rich_text.map((rt) => rt.plain_text).join('')
}
if ('title' in property && Array.isArray(property.title)) {
return property.title.map((t) => t.plain_text).join('')
}
return ''
}
Cálculo de tempo de leitura
Crie src/shared/utils/calculate-reading-time.ts:
export function calculateReadingTime(content: string): number {
const wordsPerMinute = 200
const codeWordsPerMinute = 100
const normalWordCount = content
.trim()
.split(/\s+/)
.filter((word) => word.length > 0).length
const codeLines = content
.split('\n')
.filter((line) => line.trim().length > 0).length
const normalReadingTime = normalWordCount / wordsPerMinute
const codeReadingTime = codeLines / codeWordsPerMinute
return Math.ceil(normalReadingTime + codeReadingTime)
}
Formatação de datas
Crie src/shared/utils/format-date.ts:
export function formatDate(date?: Date | string | null): string {
const months = [
'Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun',
'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez',
]
if (!date) return ''
const parsedDate = date instanceof Date ? date : new Date(date)
if (isNaN(parsedDate.getTime())) return ''
const day = String(parsedDate.getDate()).padStart(2, '0')
const month = months[parsedDate.getMonth()]
const year = parsedDate.getFullYear()
return `${day} ${month}, ${year}`
}
export function formatRelativeDate(date?: Date | string | null): string {
if (!date) return ''
const parsedDate = date instanceof Date ? date : new Date(date)
if (isNaN(parsedDate.getTime())) return ''
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - parsedDate.getTime()) / 1000)
if (diffInSeconds < 60) return 'agora mesmo'
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes} ${minutes === 1 ? 'minuto' : 'minutos'} atrás`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours} ${hours === 1 ? 'hora' : 'horas'} atrás`
}
if (diffInSeconds < 259200) {
const days = Math.floor(diffInSeconds / 86400)
return `${days} ${days === 1 ? 'dia' : 'dias'} atrás`
}
return formatDate(date)
}
7. Repository Pattern
Crie src/features/notion-repository.ts:
import { NotionClient } from '@/shared/lib/notion'
import { NotionPage } from '@/types/notion'
import { BlockObjectRequest } from '@notionhq/client/build/src/api-endpoints'
export const createNotionRepository = (databaseId: string) => {
return {
async fetchPages() {
const response = await NotionClient.databases.query({
database_id: databaseId,
filter: {
property: 'Status',
select: { equals: 'Live' },
},
})
return response.results as NotionPage[]
},
async fetchBySlug(url: string) {
const response = await NotionClient.databases.query({
database_id: databaseId,
filter: {
property: 'Url',
rich_text: { equals: url },
},
})
return response.results[0] as NotionPage | undefined
},
async fetchPageBlocks(id: string) {
const res = await NotionClient.blocks.children.list({ block_id: id })
return res.results as BlockObjectRequest[]
},
async getAllPosts() {
const response = await NotionClient.databases.query({
database_id: databaseId,
filter: {
property: 'Status',
select: { equals: 'Live' },
},
sorts: [{ property: 'Created', direction: 'descending' }],
})
return response.results as NotionPage[]
},
async getLatestPosts(limit: number = 3) {
const response = await NotionClient.databases.query({
database_id: databaseId,
filter: {
property: 'Status',
select: { equals: 'Live' },
},
sorts: [{ property: 'Created', direction: 'descending' }],
})
const posts = response.results as NotionPage[]
return posts.slice(0, limit)
},
}
}
8. Service Layer
Crie src/services/notion-posts.ts:
import { createNotionRepository } from '@/features/notion-repository'
export const NotionPosts = createNotionRepository(
process.env.NOTION_DATABASE_ID_POSTS!,
)
9. Mapper para transformação de dados
Crie src/mappers/notion.ts:
import { NotionPage } from '@/types/notion'
export interface Post {
name: string
title: string
url: string
description: string
created: string
tags: string[]
cover: string | null
}
function getFirstPlainText(field?: { plain_text: string }[]): string {
return field?.[0]?.plain_text ?? ''
}
function getCoverUrl(post: NotionPage): string | null {
const cover = post.cover
if (!cover) return null
if (cover.type === 'external') return cover.external.url
if (cover.type === 'file') return cover.file.url
return null
}
export const NotionMapper = {
async fromNotion(post: NotionPage): Promise<Post> {
const { Name, Title, Url, Description, Created, Tags } = post.properties
return {
title: getFirstPlainText(Title?.rich_text),
name: getFirstPlainText(Name?.title),
url: getFirstPlainText(Url?.rich_text),
description: getFirstPlainText(Description?.rich_text),
created: Created?.created_time ?? '',
tags: Tags?.multi_select?.map((tag) => tag.name) ?? [],
cover: getCoverUrl(post),
}
},
async many(posts: NotionPage[]): Promise<Post[]> {
return Promise.all(posts.map(this.fromNotion))
},
}
10. Páginas do Next.js
Lista de posts
Crie src/app/posts/page.tsx:
import { NotionMapper } from '@/mappers/notion'
import { NotionPosts } from '@/services/notion-posts'
export const dynamic = 'force-dynamic'
export default async function PostsPage() {
const rawPosts = await NotionPosts.getAllPosts()
const posts = await NotionMapper.many(rawPosts)
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Blog Posts</h1>
<div className="grid gap-6">
{posts.map((post) => (
<article key={post.url} className="border-b pb-6">
<h2 className="text-xl font-semibold mb-2">
<a href={`/posts/${post.url}`} className="hover:underline">
{post.title}
</a>
</h2>
<p className="text-gray-600 mb-2">{post.description}</p>
<div className="flex gap-2 text-sm text-gray-500">
<span>{formatRelativeDate(post.created)}</span>
{post.tags.map((tag) => (
<span key={tag} className="bg-gray-100 px-2 py-1 rounded">
{tag}
</span>
))}
</div>
</article>
))}
</div>
</main>
)
}
Página individual do post
Crie src/app/posts/[slug]/page.tsx:
import 'highlight.js/styles/github-dark.css'
import { NotionClient } from '@/shared/lib/notion'
import { NotionPosts } from '@/services/notion-posts'
import bookmarkPlugin from '@notion-render/bookmark-plugin'
import { NotionRenderer } from '@notion-render/client'
import hljsPlugin from '@notion-render/hljs-plugin'
import { BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints'
import { extractText } from '@/shared/utils/extract-text'
import { formatRelativeDate } from '@/shared/utils/format-date'
import { calculateReadingTime } from '@/shared/utils/calculate-reading-time'
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await NotionPosts.fetchBySlug(slug)
if (!post) return {}
const title = extractText(post.properties.Title)
const description = extractText(post.properties.Description)
return { title, description }
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await NotionPosts.fetchBySlug(slug)
if (!post) return <div>Post não encontrado</div>
const blocks = await NotionPosts.fetchPageBlocks(post.id)
// Configurar o renderer
const renderer = new NotionRenderer({ client: NotionClient })
renderer.use(hljsPlugin({}))
renderer.use(bookmarkPlugin(undefined))
// Renderizar o conteúdo
const html = await renderer.render(...(blocks as BlockObjectResponse[]))
// Extrair texto para cálculo de tempo de leitura
const content = (blocks as BlockObjectResponse[])
.map((block) => {
if ('paragraph' in block) {
return block.paragraph.rich_text.map((rt) => rt.plain_text).join('')
}
if ('heading_1' in block) {
return block.heading_1.rich_text.map((rt) => rt.plain_text).join('')
}
// Adicione outros tipos de bloco conforme necessário
return ''
})
.join(' ')
.trim()
const readingTime = calculateReadingTime(content)
return (
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6">
<div className="flex items-center gap-2 text-sm text-gray-500 mb-4">
<span>{readingTime} min de leitura</span>
<span>•</span>
<span>{formatRelativeDate(post.properties.Created.created_time)}</span>
</div>
<div className="flex gap-2 mb-4">
{post.properties.Tags?.multi_select.map((tag) => (
<span key={tag.name} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm">
{tag.name}
</span>
))}
</div>
<h1 className="text-4xl font-bold mb-4">
{post.properties.Title?.rich_text[0].plain_text}
</h1>
</div>
<article
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
</main>
)
}
11. Otimizações e boas práticas
Cache e revalidação
Para melhorar a performance, configure o cache do Next.js:
// Em suas funções de fetch
export const revalidate = 3600 // Revalida a cada hora
Geração estática
Para posts que não mudam frequentemente, use Static Site Generation:
export async function generateStaticParams() {
const posts = await NotionPosts.getAllPosts()
return posts.map((post) => ({
slug: extractText(post.properties.Url),
}))
}
Tratamento de erros
Implemente tratamento robusto de erros:
try {
const post = await NotionPosts.fetchBySlug(slug)
if (!post) {
notFound()
}
// ... resto do código
} catch (error) {
console.error('Erro ao buscar post:', error)
throw new Error('Falha ao carregar o post')
}
12. Recursos avançados
Busca e filtros
Implemente funcionalidades de busca:
async searchPosts(query: string) {
const response = await NotionClient.databases.query({
database_id: databaseId,
filter: {
or: [
{
property: 'Title',
rich_text: { contains: query },
},
{
property: 'Description',
rich_text: { contains: query },
},
],
},
})
return response.results as NotionPage[]
}
Webhooks para atualizações
Configure webhooks para atualizar o cache quando o conteúdo mudar no Notion.
SEO otimizado
Implemente metadados dinâmicos completos:
export async function generateMetadata({ params }) {
// ... buscar post
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: post.cover ? [post.cover] : [],
},
// Adicione mais campos conforme necessário
}
}
Conclusão
Básicamente é isso, não tem nada muito complexo ou diferente do comum. Sinta-se à vontade para visitar o repositório que eu criei para sanar dúvidas que talvez eu não tenha respondido aqui.
Links
- Repositório: https://github.com/lukaslumiere/notion-nextjs-blog
- API do Notion: https://developers.notion.com/docs/getting-started