MDX não é só texto quando gera uma rota pública

1 de outubro de 2026·6 min de leitura·

Um arquivo MDX pode parecer inofensivo: título, parágrafos e um botão no final.

Até ele definir uma URL que vai para o sitemap, aparecer em uma listagem pública, gerar metadados e apontar alguém para um agendamento. A partir daí, o artigo virou parte do sistema em produção.

A consequência é simples: revisão editorial continua necessária, mas não basta. O post precisa passar pelo mesmo tipo de verificação que protege uma mudança de rota: contrato de entrada, build e smoke do artefato final.

O caminho do arquivo já é uma decisão pública

Neste site, o padrão de arquivos em content/posts não serve só para organizar pastas. A configuração do Velite aceita apenas posts no formato posts/AAAA/MM/DD/slug/index.mdx e deriva a URL a partir desse caminho.

velite.config.ts
    posts: {
      name: "Post",
      pattern: "posts/**/index{,.en}.mdx",
      schema: postsSchema.transform((data, { meta }) => {
        const filePath = (meta.path ?? "").replace(/\\/g, "/");
        const isEn = /\.en\.mdx$/.test(filePath);
        const locale: Locale = isEn ? "en" : "pt";
 
        const match = filePath.match(
          /content\/posts\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)\/index(?:\.en)?\.mdx$/,
        );
        if (!match) {
          throw new Error(
            `Post path does not match expected pattern: ${filePath}`,
          );
        }
        const [, year, month, day, slug] = match;
        const permalink =
          locale === "pt"
            ? `/${year}/${month}/${day}/${slug}`
            : `/en/${year}/${month}/${day}/${slug}`;
 
        const rawBody = (meta.content ?? "") as string;
        const rt = readingTime(rawBody);
 
        const stripped = rawBody
          .replace(/```[\s\S]*?```/g, "")
          .replace(/[#*_>`~\-[\]()!]/g, "")
          .replace(/\s+/g, " ")
          .trim();
        const excerpt =
          data.description && data.description.length > 0
            ? data.description
            : stripped.slice(0, 160) + (stripped.length > 160 ? "…" : "");
 
        return {
          ...data,
          body: rawBody,
          locale,
          slug,
          permalink,
          readingTime: Math.max(1, Math.round(rt.minutes)),
          excerpt,
        };
      }),
    },
  },
  prepare: ({ posts }) => {
    const bySlug: Record<string, { pt?: string; en?: string }> = {};
    for (const p of posts) {
      bySlug[p.slug] ??= {};
      bySlug[p.slug][p.locale as Locale] = p.permalink;
    }
    for (const p of posts) {
      const sib = bySlug[p.slug];
      p.translationSlug = p.locale === "pt" ? sib?.en : sib?.pt;
    }
  },
content/posts/2026/10/01/mdx-nao-e-so-texto/index.mdx
→ /2026/10/01/mdx-nao-e-so-texto

O frontmatter completa o contrato. title, description, date, draft, tags e comentários não são anotações que o renderer pode ignorar. São dados que entram na coleção de posts e alimentam páginas, listagens e metadados.

Por isso, trocar um slug no fim da escrita não é uma revisão estética. É trocar um endereço público. Antes de considerar o texto pronto, confirme o caminho final, links internos e os campos que o build exige.

Publicar não é só trocar draft para false

O estado público deste blog depende de duas condições: o post não pode ser draft e a data precisa já ter chegado. A regra está explícita na função de visibilidade:

features/blog/lib/visibility.ts
export function isScheduled(
  post: Schedulable,
  now: Date = new Date(),
): boolean {
  const at = new Date(post.date).getTime();
  if (Number.isNaN(at)) return false;
  return at > now.getTime();
}
 
/** Público: publicado (não-draft) e com a data já vencida. */
export function isPublic(post: Publishable, now: Date = new Date()): boolean {
  return !post.draft && !isScheduled(post, now);
}

Isso evita dois erros de operação diferentes:

  • colocar no ar um rascunho que ainda não terminou;
  • vazar um post agendado porque algum trecho de código filtrou apenas draft.

A data precisa ter timezone explícito. 2026-10-01T11:00:00-03:00 descreve um instante; “1º de outubro às 11h” depende de quem está lendo e de qual servidor está calculando a visibilidade.

A mesma separação aparece em automações que escrevem fora do sistema. No post sobre idempotência em efeitos externos, retry e efeito são tratados como coisas diferentes. Em conteúdo, rascunho, agendamento e publicação também precisam ser estados distintos. Misturá-los produz publicação acidental ou um post pronto que nunca aparece.

PT-only também é uma decisão de release

Traduzir um post é útil quando a versão em inglês mantém os fatos e os links. Mas criar uma rota EN só para parecer bilíngue deixa uma falha pública para alguém encontrar.

O pipeline deste site só preenche translationSlug quando encontra o arquivo irmão no mesmo slug. O sitemap só anuncia hreflang quando essa contraparte existe. Sem par, o post PT continua válido e não ganha uma alternância para uma página inexistente.

app/sitemap.ts
  const allPosts = rawPosts as unknown as Post[];
  for (const p of allPosts.filter((x) => isPublic(x, now))) {
    const selfUrl = `${siteConfig.url}${p.permalink}`;
    // Only advertise alternates when a real translation exists — otherwise we'd
    // emit a self-referential hreflang for the "other" language, which is a
    // non-reciprocal pair (mirrors the buildMetadata head fix).
    let alternates: Entry["alternates"];
    if (p.translationSlug) {
      // translationSlug is an absolute path (starts with `/`), prepend site URL.
      const otherUrl = `${siteConfig.url}${p.translationSlug}`;
      alternates = {
        languages:
          p.locale === "pt"
            ? { "pt-BR": selfUrl, en: otherUrl }
            : { en: selfUrl, "pt-BR": otherUrl },
      };
    }
 
    entries.push({
      url: selfUrl,
      lastModified: p.updated ? new Date(p.updated) : new Date(p.date),
      ...(alternates ? { alternates } : {}),
    });

A regra prática é menos elegante em planilha e mais segura no navegador:

  1. publique PT quando a fonte e a revisão estiverem fechadas;
  2. publique EN apenas quando houver paridade factual real;
  3. teste as duas URLs quando o par existir;
  4. não derive rota por string e torça para ela resolver.

O build é onde texto encontra runtime

Um arquivo pode estar bem escrito e ainda falhar na única condição que importa: ser renderizado no build que vai para produção.

No repositório deste site, pnpm build executa primeiro a geração do Velite e depois o build do Next.js, como definido no package.json:

package.json
"scripts": {
  "predev": "velite build",
  "build": "velite build && next build"
}

Esse é o ponto de encontro entre frontmatter, MDX, componentes, tipos e rotas.

Depois do build, ainda falta a verificação que o comando não enxerga sozinho:

  • iniciar o artefato final;
  • pedir a URL nova por HTTP;
  • confirmar 200, título e trecho principal;
  • conferir canonical e, se houver, alternates de idioma;
  • seguir o CTA e validar o destino real.

Uma revisão que só abre o arquivo fonte pode aprovar um link quebrado, uma rota inexistente ou uma CTA que perdeu o destino. Um smoke só de HTTP pode aprovar uma página vazia. Os dois gates se complementam.

Um release pequeno, mas completo

Para conteúdo que cria superfície pública, o fluxo mínimo é este:

fonte verificável
→ MDX com contrato válido
→ links e frontmatter revisados
→ lint, tipo, testes e build
→ smoke da rota gerada
→ PR, CI e deploy
→ smoke em produção

Isso não transforma cada artigo em um projeto de engenharia de duas semanas. Só elimina a suposição perigosa de que texto não quebra produção.

Fontes

  • Velite config do site (velite.config.ts, trecho acima) — schema, padrão de caminho, URL derivada e pareamento de idiomas.
  • Regra de visibilidade (features/blog/lib/visibility.ts, trecho acima) — draft e data como condições independentes de publicação.
  • Sitemap do site (app/sitemap.ts, trecho acima) — inclusão de posts públicos e alternates apenas para pares reais.
  • Scripts de build (package.json, trecho acima) — geração de conteúdo antes do build do Next.js.

Próximo passo

Se sua equipe já publica documentação ou conteúdo que cria rotas, integrações e CTAs, mas a aprovação termina no texto, traga esse workflow para um diagnóstico de 30 minutos.

Posts relacionados

5 min de leitura

O retry não é o problema. Repetir o efeito é.

Um timeout não diz se a escrita externa aconteceu. Antes de automatizar uma ação com IA, defina a chave que representa a intenção, o registro do efeito e o caminho para conflito.

6 min de leitura

Um agente não amadurece no prompt

Internet instável, um canal bloqueado e um QR Code impresso mostraram a diferença entre testar IA e operar um agente de verdade.