www-data@lukaslumiere.com:~$$ open contato.sh

11 min de leitura

18 Jun, 2025
Programação
Typescript
Didático

Adeus, 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

  1. Acesse https://www.notion.so/my-integrations
  2. Clique em "New integration"
  3. Configure o nome e workspace
  4. 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