NextJS
VercelAPI
Vercel
SEO com NextJS: SSG/ISR, Webhooks do Notion e Deploy Automatizado na Vercel
Otimizei meu blog em Next.js com SSG e ISR, integrei webhooks do Notion e automatizei os deploys na Vercel — tudo num fluxo leve e contínuo.
Última atualização realizada em:
Você pode acessar o código completo do projeto diretamente no GitHub através deste link: clique aqui
“Por que construímos um blog?” — essa é uma questão estritamente relevante quando pensamos em transformar ideias em algo maior que simples textos disponibilizados na internet através dessa ferramenta.
Um blog não deveria ser usado apenas para materializar ideias e despejar conteúdos, mas sim uma ferramenta estratégica para construir autoridade, posicionamento e construir conexões relevantes com uma comunidade em torno de um tema em comum.
Mas nada disso importa se ele não puder ser encontrado. Se o blog é invisível aos mecanismos de busca, não haverá possibilidade de ser relevante. Afinal, quem não é visto, não é lembrado. Por este motivo que, SEO e performance técnica se tornam peças fundamentais no processo de construção, desenvolvimento e manutenção de um blog.
Tabela de Conteúdos
Entendendo o NextJS: SSG, SSR, CSR e ISRPor que SSG é melhor para blogs abertos (público)?Por que deixar SSG p/ depois da 1ª versão?Integração com Webhook NotionAPICriando endpoint de Webhook no backend NodeJSValidando segurança dos eventosFluxo de Deploy automatizado com Vercel SDKImplementação do código de deployConectando os eventos ao fluxo de buildConstruindo o SSG no App NextJSDesafios e soluções encontradasProblema de múltiplos deploys1. Reduzindo o número de eventos recebidos2. Validando a coleção de artigos3. Implementando batching com timer4. Nova rota com batching aplicadoProblema extra: O que fazer quando a instância EC2 trava durante o build?1. Resolução imediata (quando a instância já travou)2. Prevenção (evitar que o problema ocorra novamente)Roadmap e próximos passosConclusão
Nesta parte do conteúdo, avançaremos no entendimento da stack escolhida e da arquitetura do projeto.
O frontend está escrito em NextJS, a API backend em NodeJS, mantendo a separação entre camadas. O deploy do frontend ocorre na Vercel, enquanto o backend é hospedado em uma instância AWS EC2.
Entendendo o NextJS: SSG, SSR, CSR e ISR
O NextJS brilha na construção de aplicações do tipo SPA (Single Page Application), ou seja que, além de oferecer o recurso de renderização inteiramente no navegador do usuário, possui também funcionalidades que ampliam e flexibilizam a performance dessas aplicações.
De forma simplificada, um site nada mais é do que dados organizados para serem consumidos em uma interface web. O desafio surge porque esses dados mudam constantemente: o mundo digital é dinâmico, com bilhões de conteúdos sendo criados e atualizados diariamente.
Se os sites fossem estáticos e imutáveis, bastaria servir páginas HTML fixas. Mas como a web exige dinamismo e velocidade, a comunidade de desenvolvimento criou diferentes estratégias de renderização para equilibrar desempenho, atualização e experiência do usuário.
Dessas necessidades surgiram quatro abordagens principais que o Next.js suporta:
- SSG (Static Site Generation - Gerão de Site Estático): Páginas são pré-renderizadas no momento do build e servidas como HTML estático.
- SSR (Server-Side Rendering - Renderização do lado do Servidor): O servidor monta o HTML completo a cada requisição e o envia ao navegador.
- CSR (Client-Side Rendering - Renderização do lado do Cliente): O navegador carrega um pacote inicial mínimo e constrói a interface dinamicamente via JavaScript.
- ISR (Incremental Static Regeneration - Regeneração Estática Incremental): Mistura os benefícios do SSG com a capacidade de atualizar páginas específicas sob demanda, sem precisar reconstruir o site inteiro.
Por que SSG é melhor para blogs abertos (público)?
Entre as abordagens de renderização suportadas pelo NextJS, todas poderiam desempenhar a função de construção e execução de um blog. Umas se destacam melhor que outras, a depender do objetivo estratégico do projeto.
Como estou desenvolvendo um blog aberto, voltado para aumentar meu posicionamento profissional e educacional, a abordagem mais adequada é o SSG (Static Site Generation).
E por quê? — o SSG gera páginas estáticas no momento do build, o que garante duas vantagens essenciais para blogs:
- SEO otimizado: o conteúdo já chega aos motores de busca em HTML pronto para ser indexado.
- Performance superior: como as páginas são pré-renderizadas, a entrega é mais rápida, o que também contribui para melhorar o ranqueamento do buscador (Google por exemplo).
Apesar de estratégias como o SSR (Server-Side Rendering) e o ISR (Incremental Site Regeneration) também oferecerem boa indexação, o SSG se destaca pela velocidade de entrega e pela simplicidade inicial de implementação.
Vale destacar que essa não é uma decisão definitiva: pelo tamanho e importância do blog, a migração entre um site SSG para um ISR pode, e deve, acontecer naturalmente conforme a demanda por conteúdos crescer. Como estou apenas inicializando o projeto, não faz sentido implementar um ISR já que a atualização de conteúdos não será num ritmo acelerado — e é possível que para um blog pessoal isso não venha a ocorrer.
Por que deixar SSG p/ depois da 1ª versão?
Já que está mais claro que a estratégia SSG é a mais adequada para o cenário em que estou trabalhando. A questão que fica é — “Por que então primeiro publiquei uma versão em CSR para então realizar a migração para um SSG?”
A resposta está no equilíbrio entre velocidade de entrega e maturidade da solução. Desde a concepção da ideia do projeto, eu sabia que o blog deveria evoluir para SSG, porém na sua versão inicial, implementar essa estratégia seria custoso. Imagina ter que realizar o deploy manualmente na Vercel sempre que fosse preciso validar uma alteração.
Eu poderia apenas lançar o projeto quando essas funcionalidades fossem implementadas. Porém colocar o site em produção custaria muito mais tempo, e essa não era a minha intenção. Optar por disponibilizar o site o mais rápido possível, me permitiu experimentar a realidade de uma aplicação online — identificando problemas, coletando aprendizados e ajustando a experiência.
Agora, com a primeira versão validada e os aprendizados consolidados, faz sentido migrar para SSG, garantindo indexação pelos motores de busca, melhor desempenho e evolução consistente do projeto.
Integração com Webhook NotionAPI
Agora que saímos da parte conceitual, é hora de olhar para a prática: como manter um site estático sempre atualizado com novos conteúdos.
O SSG gera todas as páginas no momento do build. Isso significa que, depois de construído, nada muda sozinho. Então surge a questão: “Como atualizar o site sempre que novos conteúdos forem publicados?”
A resposta é simples: reconstruindo o site.
Mas isso não precisa ser manual — podemos automatizar o processo.
O fluxo é o seguinte:
- Um conteúdo é atualizado no Notion.
- Essa alteração dispara um webhook.
- O backend recebe esse evento e aciona o SDK da Vercel para realizar um novo deploy.
Em outras palavras, precisamos apenas unir dois pontos:
- O webhook do NotionAPI, que sinaliza mudanças em tempo real.
- O SDK da Vercel, que permite acionar programaticamente o rebuild do site.
Criando endpoint de Webhook no backend NodeJS
Para conhecimento técnico completo sobre a construção de Endpoints de Webhook com Notion API, veja a documentação oficial da API aqui.
Vamos começar pela primeira parte desse fluxo.
Começamos criando o endpoint responsável por receber os eventos do Notion:
app.post('/posts/webhooks/notion', async (req, res) => { // Descreva a funcionalidade da rota aqui... })
Simples como qualquer outra rota no backend, aqui definimos um ponto de entrada dedicado para as notificações enviadas pela Notion API.
Validando segurança dos eventos
Sem validação, qualquer pessoa poderia enviar requisições para esse endpoint e disparar rebuilds indevidos, o que abre brechas para ataques de negação de serviço ou manipulações maliciosas.
Para evitar isso, implementamos uma camada de middleware no ExpressJS que valida a assinatura de cada requisição:
app.post( '/posts/webhooks/notion', async (req, res, next) => { try { const { body } = req const sign = (req.headers['X-Notion-Signature'] || req.headers['x-notion-signature']) if ( !sign || typeof sign !== 'string' || !verifySignature( sign, process.env.NOTION_WEBHOOK_TOKEN ?? '', JSON.stringify(body), ) ) return res.status(401).json({ message: 'Unauthorized request' }) return next() } catch (e) { // eslint-disable-next-line no-console console.error(e) return res.status(500).json({ error: 'Internal server error' }) } }, // Handler de manipulação do evento de deploy )
O ponto central está na função
verifySignature, que compara o hash recebido no cabeçalho X-Notion-Signature com a assinatura gerada a partir do corpo da requisição e do token secreto configurado:import { createHmac, timingSafeEqual } from 'crypto' export function verifySignature( signature: string, secret: string, bodyString: string, ): boolean { const calculatedSignature = `sha256=${createHmac('sha256', secret).update(bodyString).digest('hex')}` const isTrustedPayload = timingSafeEqual( Buffer.from(calculatedSignature), Buffer.from(signature), ) return isTrustedPayload }
Essa lógica, recomendada pela própria documentação do Notion, garante que apenas eventos legítimos — assinados com o token correto — possam acionar a reconstrução do site.
Fluxo de Deploy automatizado com Vercel SDK
O próximo passo do fluxo é automatizar o rebuild do blog diretamente na Vercel, sem depender de ações manuais. Para isso, usamos o SDK oficial da Vercel, que permite criar um novo deploy programaticamente.
Implementação do código de deploy
A ideia é simples: inicializamos uma instância do SDK, passamos as credenciais e os dados do projeto, e em seguida criamos o deploy.
/* eslint-disable no-console */ import { Vercel } from '@vercel/sdk' export async function createDeployOnVercel() { const vercel = new Vercel({ bearerToken: process.env.VERCEL_API_TOKEN, }) try { const createResponse = await vercel.deployments.createDeployment({ requestBody: { name: process.env.VERCEL_PROJECT_NAME!, project: process.env.VERCEL_PROJECT_ID!, target: process.env.VERCEL_TARGET!, gitSource: { type: process.env.VERCEL_SOURCE_TYPE! as any, org: process.env.VERCEL_SOURCE_ORG!, repo: process.env.VERCEL_SOURCE_REPO!, ref: process.env.VERCEL_SOURCE_REF! as any, }, }, }) console.log( `Deployment created: ID ${createResponse.id} and status ${createResponse.status}`, ) } catch (error) { console.error( error instanceof Error ? `Error: ${error.message}` : String(error), ) } }
Conectando os eventos ao fluxo de build
Uma vez validada a assinatura do evento, o próximo passo é acionar a função de deploy na Vercel. Isso é feito no handler seguinte da rota:
app.post( '/posts/webhooks/notion', async (req, res, next) => { // fluxo de verificação do hash e integridade do evento Notion }, async (_, res) => { try { createDeployOnVercel() return res.status(201).send() } catch (e) { // eslint-disable-next-line no-console console.error(e) return res.status(500).json({ error: 'Internal server error' }) } }, )
O que está acontecendo aqui?
- O primeiro handler atua como middleware, validando o hash do evento (
X-Notion-Signature). Se falhar, a requisição é bloqueada imediatamente.
- O segundo handler só é executado se a validação passar, e sua única responsabilidade é chamar
createDeployOnVercel().
- A resposta
201 Createdindica ao Notion que o evento foi processado com sucesso.
- O deploy roda de forma assíncrona, ou seja, a API não precisa esperar a conclusão do processo para responder ao Notion.
Construindo o SSG no App NextJS
Com o backend pronto (webhook do Notion + deploy automatizado na Vercel), precisamos garantir que o frontend realmente rode em SSG.
A ideia é:
- forçar cache das requisições à API (para que o Next considere os dados estáticos no build) e
- pré-gerar as rotas dinâmicas dos posts com
generateStaticParams.
Definimos
cache: 'force-cache' nos fetches que alimentam as páginas. Isso instrui o App Router a tratar essas chamadas como estáticas durante o build.// ./app/src/services/get.post.data.ts export async function getPostData({ slug }: Request): Promise<Result> { const result = await fetch(`${config.api.baseUrl}/post/${slug}`, { cache: 'force-cache', }) if (!result.ok) throw new Error('Invalid post slug') const { recordMap, properties } = await result.json() return { recordMap, properties } } // ./app/src/services/get.posts.list.ts export async function getPostsList() { const result = await fetch(`${config.api.baseUrl}/posts`, { cache: 'force-cache', }) let posts: Cover[] = [] if (result.ok) posts = await result.json() return posts }
Na rota dinâmica dos artigos (
/post/[slug]), use generateStaticParams para informar ao Next quais páginas devem ser geradas no build.// ./app/src/app/post/[slug]/page.tsx export async function generateStaticParams() { const posts = await getPostsList() return posts.map(({ slug }) => ({ slug, })) } /* Para reforçar o comportamento estático e evitar geração em runtime de slugs desconhecidos, você pode adicionar: */ export const revalidate = false export const dynamicParams = false
Com esses passos, o app passa a operar em SSG: os dados são coletados no build, as páginas são pré-geradas e servidas como HTML estático — alinhado ao objetivo de SEO e performance do projeto.
Desafios e soluções encontradas
Durante a construção desse fluxo, não foi diferente do que acontece em qualquer projeto real: sempre surgem problemas inesperados, ajustes necessários e decisões que precisam ser revistas no caminho. O código que mostrei até aqui funciona, mas não cobre todos os cenários possíveis — e foi justamente enfrentando esses desafios que surgiram aprendizados importantes.
Nesta seção, registro alguns dos principais problemas encontrados e as soluções (ou caminhos) que adotei para lidar com eles.
Problema de múltiplos deploys
Se analisarmos o fluxo de rebuild automático, percebemos que qualquer alteração realizada no Notion gera uma nova build. Isso pode levar a custos excessivos, aumento do tempo de build e até riscos de instabilidade.
Para mitigar esse problema, precisamos de uma solução em camadas: filtrar eventos desnecessários, validar se a alteração realmente importa para o blog e aplicar um mecanismo de batching que agrupe várias mudanças em um único deploy.
1. Reduzindo o número de eventos recebidos

A primeira medida é limitar os eventos que recebemos do Notion. No dashboard da API do Notion, em “subscribed events”, basta manter apenas a opção “page properties updated” marcada.
Isso garante que o webhook só será chamado quando houver alterações nas propriedades de páginas, que de fato interessam para atualizar os artigos do blog.
2. Validando a coleção de artigos
Além de filtrar os eventos, também precisamos garantir que a alteração se refere à coleção de artigos do blog (identificada por
NOTION_COLLECTION_ID).E fazemos isso da seguinte forma:
app.post( '/posts/webhooks/notion', async (req, res, next) => { // fluxo de verificação do hash e integridade do evento Notion }, async (req, res) => { try { const { body } = req const { type, data } = body if ( type === 'page.properties_updated' && data.parent.data_source_id === process.env.NOTION_COLLECTION_ID ) createDeployOnVercel() return res.status(201).send() } catch (e) { // eslint-disable-next-line no-console console.error(e) return res.status(500).json({ error: 'Internal server error' }) } }, )
Com essa lógica, apenas modificações em páginas que pertencem diretamente à coleção de artigos do blog irão disparar o fluxo de deploy.
3. Implementando batching com timer
Mesmo com as validações, ainda pode haver situações em que várias alterações em sequência (como editar título, tags e data de um mesmo artigo) gerem múltiplos deploys desnecessários.
Para resolver isso, implementamos um timer de controle que funciona como um debounce: ele espera um intervalo definido (5 minutos por padrão) antes de disparar o deploy, agrupando as mudanças em um único rebuild.
/* eslint-disable no-console */ import { createDeployOnVercel } from '../services/create.deploy.on.vercel' const DEFAULT_TIME = Number( process.env.DEFAULT_TIME_DEPLOY_CLOCK || 5 * 60 * 1000, ) enum TIMER_STATUS { READY, SCHEDULED, } export function DeployTimer(time = DEFAULT_TIME) { let status = TIMER_STATUS.READY let timeout: NodeJS.Timeout function deploy() { if (status === TIMER_STATUS.SCHEDULED) clear() status = TIMER_STATUS.SCHEDULED timeout = setTimeout(async () => { await createDeployOnVercel() status = TIMER_STATUS.READY console.log('Deploy on Vercel Queued') }, time) console.log('Deploy on Vercel Scheduled') } function clear() { status = TIMER_STATUS.READY clearTimeout(timeout) } return { deploy, clear, } }
4. Nova rota com batching aplicado
Agora a rota de webhook passa a usar o
DeployTimer, garantindo que os deploys sejam agrupados:const timer = DeployTimer() app.post( '/posts/webhooks/notion', async (req, res, next) => { // fluxo de verificação do hash e integridade do evento Notion }, async (req, res) => { try { const { body } = req const { type, data } = body if ( type === 'page.properties_updated' && data.data_source_id === process.env.NOTION_COLLECTION_ID ) timer.deploy() return res.status(201).send() } catch (e) { // eslint-disable-next-line no-console console.error(e) return res.status(500).json({ error: 'Internal server error' }) } }, )
Com esse fluxo, conseguimos diminuir drasticamente o volume de deploys desnecessários. O sistema passa a ser capaz de:
- Filtrar eventos irrelevantes no Notion.
- Garantir que apenas alterações em artigos do blog disparem rebuilds.
- Agrupar múltiplas mudanças em um único deploy, reduzindo custos, tempo de build e aumentando a eficiência.
Esse tipo de solução simples, mas estratégica, ajuda a tornar o pipeline mais econômico, estável e escalável à medida que o blog cresce.
Problema extra: O que fazer quando a instância EC2 trava durante o build?
Durante o desenvolvimento desta etapa do projeto, voltada para melhorar o SEO, enfrentei duas situações em que a instância EC2 travou. Isso ocorreu quando havia alterações simultâneas no código do backend (API) e do frontend (NextJS), disparando actions diferentes — na Vercel e no GitHub Actions — que concorreram no processo de build e acabaram sobrecarregando a máquina.
Esse é um problema comum em ambientes de desenvolvimento real e, por isso, vale destacar duas frentes de solução: como agir quando o problema já ocorreu e como preveni-lo no futuro.
1. Resolução imediata (quando a instância já travou)
A ação manual que realizei foi:
- Acessar o console da AWS.
- Interromper a instância travada.
- Reinicializar a máquina.
Problema: ao reiniciar, a instância retorna com novo IP, exigindo que o DNS seja atualizado e propagado antes da API voltar a funcionar — um processo lento e frágil.
Solução: usar um Elastic IP da AWS. Dessa forma, mesmo que a instância seja substituída ou reiniciada, o IP público permanece o mesmo, evitando a necessidade de alterar configurações de DNS. Além disso, é possível automatizar a recuperação com um script que interrompe a instância com falha e sobe uma nova no lugar.
2. Prevenção (evitar que o problema ocorra novamente)
A causa provável do travamento foi a concorrência entre builds disparadas em paralelo (Vercel + GitHub Actions).
Solução prática:
- Retirar a responsabilidade da Vercel de rodar builds concorrentes.
- Centralizar o fluxo em um pipeline único no GitHub Actions, escrito em YAML, garantindo que os jobs não rodem simultaneamente.
- Assim, o deploy pode ser serializado, prevenindo sobrecarga e aumentando a confiabilidade.
Esse problema de travamento da instância EC2 me mostrou como desafios de infraestrutura e concorrência em pipelines podem impactar até mesmo projetos pequenos. Aqui apresentei soluções práticas para contornar e prevenir a falha, mas vou explorar esse tema com mais profundidade em um artigo dedicado, abordando diferentes estratégias de resiliência em builds e deploys na AWS.
Roadmap e próximos passos
- Implementação de testes automatizados
- Melhoria de design para refletir um blog mais moderno
- Adicionar scripts de monitoramento de performance do conteúdo
- Utilização de ferramenta de bundle para diminuir o tamanho da aplicação devido ao monorepo
- Refatoração do código para desacoplar e preparar a aplicação para futuras substituições
Conclusão
Existem várias formas de chegar a um mesmo objetivo técnico. No caso do blog, poderia ter seguido direto para o ISR, ou começado com SSR, ou até mantido o CSR por mais tempo. Mas cada escolha precisa considerar o contexto, os recursos disponíveis e o estágio do projeto.
O que realmente importa é tomar decisões conscientes, entendendo os trade-offs e buscando a estratégia que entregue resultados de forma eficiente e sustentável com o que temos em mãos no momento. Foi assim que evoluí do CSR inicial para o SSG, integrei os webhooks do Notion, automatizei o deploy na Vercel e tratei os desafios que surgiram no caminho.
No fim, mais do que a tecnologia escolhida, o valor está na capacidade de adaptar, aprender e ajustar a estratégia para que ela seja a mais eficaz dentro do cenário real do projeto.