Murad Library
Murad LibraryREF-0489MD

Tutorial completo para construir um site comercial sofisticado com Ghost

Catalogued
Reading
40 min read

O Ghost pode sustentar um site institucional muito mais sofisticado do que um blog convencional. Ele pode entregar diretamente uma homepage cinematográfica, páginas de serviços, cases, equipe, formulários, newsletter e conteúdo editorial por meio de um tema Handlebars personalizado. Também pode funcionar apenas como CMS, entregando conteúdo pela API para um frontend em Astro.

Este tutorial ensina as duas arquiteturas de ponta a ponta. A primeira implementação usa Ghost tradicional + tema próprio + CSS + GSAP. Ela é a recomendação principal para a maioria dos sites empresariais. A segunda usa Ghost headless + Astro, indicada quando o frontend precisa combinar várias fontes de dados, rotas especiais, catálogos estruturados ou comportamento semelhante a uma aplicação.

O projeto guiado usa uma empresa fictícia chamada Atelier Norte. Substitua nomes, domínios, textos, cores e imagens pelos dados reais do seu projeto.

Base técnica: este guia foi verificado para Ghost 6 e para a API v6.0. O Ghost 6 exige Node.js 22 nas instalações oficiais e removeu limit=all: consultas com mais de 100 registros precisam de paginação. Revise as breaking changes antes de atualizar um projeto existente.

Resumo executivo

Para um site com homepage impactante, serviços, projetos, equipe, depoimentos, blog, newsletter, contato e animações, escolha:

Ghost tradicional
    +
tema Handlebars próprio
    +
CSS ou Tailwind compilado
    +
GSAP usado com moderação

Essa arquitetura mantém o fluxo editorial simples:

Editor publica no Ghost
        ↓
Ghost renderiza o tema
        ↓
Conteúdo aparece imediatamente

Você recebe nativamente:

  • URLs e templates do Ghost;
  • metadados de SEO;
  • RSS;
  • sitemap;
  • navegação;
  • autores, tags e posts;
  • editor e cards;
  • membros, newsletter e Portal;
  • preview editorial;
  • um único deploy para CMS e site.

Escolha Ghost headless com Astro quando houver uma necessidade objetiva:

  • catálogo com dados externos;
  • frontend compartilhado com outro sistema;
  • mapa, filtros ou simuladores complexos;
  • composição de Ghost, CRM e APIs;
  • várias propriedades digitais usando o mesmo conteúdo;
  • equipe confortável em manter CMS e frontend separadamente;
  • deploy estático ou edge como requisito arquitetural.

No headless, o fluxo passa a ser:

Editor publica no Ghost
        ↓
Webhook dispara um build
        ↓
Astro consulta a Content API
        ↓
Site é publicado novamente

O ganho é liberdade. O custo é assumir manualmente SEO, rotas, RSS, sitemap, busca, preview, cache, tratamento de falhas e parte da experiência de membros.

Escolha rápida

PerguntaTema GhostGhost + Astro
Publicar e aparecer imediatamenteExcelenteDepende de SSR ou rebuild
Um único sistema para operarSimNão
SEO pronto e integradoSimVocê implementa
Newsletter e membros nativosExcelenteIntegração adicional
Preview de rascunhosNativoMais difícil
Componentes modernosPartials HandlebarsComponentes Astro
Conteúdo de várias APIsPossível, mas limitadoExcelente
Aplicação interativa complexaLimitadoExcelente
Manutenção por equipe pequenaMelhor escolhaSó com motivo claro
Hospedagem estáticaNãoSim

Metodologia e critérios

A pesquisa usou como fontes principais:

  • documentação oficial do Ghost;
  • especificação de temas, helpers, contexts, routing e GScan;
  • Content API, JavaScript client e webhooks oficiais;
  • requisitos oficiais do Ghost 6;
  • documentação oficial do Astro;
  • documentação atual do GSAP e do Tailwind CSS;
  • documentação de segurança, hospedagem, members e updates do Ghost.

Os exemplos foram avaliados por:

  • simplicidade operacional;
  • compatibilidade com Ghost 6;
  • experiência editorial;
  • desempenho e SEO;
  • acessibilidade;
  • segurança de formulários e APIs;
  • possibilidade de rollback;
  • facilidade de manutenção por uma pessoa ou equipe pequena.

Não foi tratado como vantagem aquilo que apenas transfere trabalho do Ghost para o desenvolvedor. O modo headless é apresentado com seus benefícios e suas obrigações reais.

Resultado que construiremos

O site final terá:

  • cabeçalho responsivo;
  • homepage com hero, serviços, cases, números, equipe, depoimentos e CTA;
  • páginas institucionais editáveis;
  • arquivo de projetos;
  • blog;
  • página individual de artigo;
  • formulário enviado para n8n ou endpoint próprio;
  • captura de newsletter;
  • animações com fallback para movimento reduzido;
  • imagens responsivas;
  • SEO técnico;
  • tratamento de erros;
  • build validado;
  • processo de deploy e rollback.

Mapa de conteúdo sugerido

ConteúdoOnde guardar no GhostPor quê
Nome, descrição, logo, redesSettingsdados globais
MenuNavigationeditável sem tocar no tema
Heropágina inicio ou theme settingstexto editorial editável
Sobrepágina sobreconteúdo longo
Serviçosposts com tag interna #servicorepetível e filtrável
Projetos/casesposts com tag interna #projetoimagem, texto, autor, data e tags
Equipeposts com tag interna #equipe ou páginausar posts se cada pessoa tiver perfil
Depoimentosposts com tag interna #depoimentocoleção repetível
Blogposts com tag pública blogarquivo editorial
Contatopágina contato + formulário do temaconteúdo e interface separados
CTA e escolhas visuaiscustom theme settingscontroles curtos e previsíveis

Tags internas do Ghost começam com # e não aparecem como taxonomia pública convencional. Elas são úteis para modelar coleções sem misturá-las ao blog.

Limite importante do Ghost como catálogo

Ghost possui posts, páginas, tags, autores, tiers e settings; ele não é um CMS schema-first como Directus ou Strapi. Um empreendimento com dezenas de campos obrigatórios, unidades, plantas, preços, coordenadas e relacionamentos pode ficar artificial se modelado apenas com tags e HTML.

Nesse caso, uma boa arquitetura é:

Ghost         -> conteúdo editorial e páginas
Banco/API     -> catálogo estruturado
Astro/Next    -> composição do frontend

Não force o Ghost a ser banco de catálogo se os dados pertencem a um sistema estruturado.

Preparação do ambiente

Produção: três opções

Ghost(Pro)

É a opção gerenciada. Você não administra Node.js, MySQL, Nginx, SSL ou updates do Core. Continua podendo enviar um tema próprio e usar Content API.

Escolha se:

  • operação do servidor não é parte do projeto;
  • disponibilidade vale mais que controle;
  • deseja suporte oficial;
  • o orçamento comporta o plano necessário para temas personalizados.

Ghost-CLI em Ubuntu

O método oficial tradicional usa:

  • Ubuntu 22.04 ou 24.04;
  • Node.js suportado;
  • MySQL 8;
  • Nginx;
  • systemd;
  • pelo menos 1 GB de RAM;
  • domínio e HTTPS.

É o caminho de produção mais maduro para uma instalação simples. O Ghost-CLI configura usuário de baixo privilégio, banco, Nginx, SSL e systemd.

Docker Compose oficial

O Compose oficial lançado com Ghost 6 ainda é documentado como preview. Ele inclui Ghost, MySQL e Caddy e pode adicionar ActivityPub e analytics self-hosted.

Escolha se:

  • você prefere administrar containers;
  • precisa dos serviços novos;
  • aceita acompanhar mudanças do tooling em preview;
  • tem backup e rollback bem testados.

Para o primeiro site comercial, Ghost-CLI ou Ghost(Pro) são as escolhas conservadoras. Docker não melhora o tema; ele muda apenas a operação do servidor.

Desenvolvimento local do tema

O método oficial cria um Ghost local com SQLite:

npm install -g ghost-cli@latest
mkdir ghost-local
cd ghost-local
ghost install local

Endereços:

Site:  http://localhost:2368
Admin: http://localhost:2368/ghost

Comandos úteis:

ghost start
ghost stop
ghost restart
ghost log
ghost ls

O modo local serve para desenvolvimento, não para produção.

Criar dados de teste

No Admin local:

  1. crie a conta proprietária;
  2. defina título e descrição;
  3. configure o menu;
  4. crie as páginas inicio, sobre, servicos e contato;
  5. publique quatro posts com tag interna #projeto;
  6. publique três posts com #servico;
  7. publique três artigos com tag pública blog;
  8. coloque feature images;
  9. teste cards de imagem, galeria, vídeo, botão, bookmark e embed;
  10. crie conteúdo longo o suficiente para testar tipografia.

Um tema parece pronto com três posts curtos e quebra quando recebe conteúdo real. Teste títulos longos, imagens verticais, texto sem imagem, múltiplos autores, tags demais e conteúdo vazio.

Arquitetura A: tema Ghost personalizado

Por que esta é a recomendação principal

O Ghost continua controlando conteúdo e apresentação:

Navegador
    ↓
Nginx ou Caddy
    ↓
Ghost
    ↓
Context + template Handlebars
    ↓
HTML, CSS e JavaScript

O HTML sai renderizado no servidor. JavaScript entra apenas onde melhora a experiência. Isso é excelente para SEO, desempenho, compartilhamento, acessibilidade e manutenção.

Criar o tema do zero

Entre na pasta de temas do Ghost local:

cd ghost-local/content/themes
mkdir atelier-norte
cd atelier-norte

Estrutura:

atelier-norte/
├── assets/
│   ├── built/
│   │   ├── screen.css
│   │   └── main.js
│   ├── fonts/
│   ├── images/
│   └── videos/
├── partials/
│   ├── site-header.hbs
│   ├── site-footer.hbs
│   ├── project-card.hbs
│   ├── service-card.hbs
│   └── newsletter.hbs
├── src/
│   ├── css/
│   │   └── screen.css
│   └── js/
│       └── main.js
├── author.hbs
├── default.hbs
├── error.hbs
├── home.hbs
├── index.hbs
├── page.hbs
├── page-contato.hbs
├── post.hbs
├── tag.hbs
└── package.json

Arquivos obrigatórios:

  • index.hbs;
  • post.hbs;
  • package.json.

default.hbs não é obrigatório, mas deve existir em praticamente todo tema profissional.

package.json

{
  "name": "atelier-norte",
  "description": "Tema institucional do Atelier Norte",
  "version": "1.0.0",
  "license": "MIT",
  "author": {
    "name": "Seu nome",
    "email": "voce@seudominio.com"
  },
  "engines": {
    "ghost-api": "v6"
  },
  "config": {
    "posts_per_page": 12,
    "card_assets": true,
    "image_sizes": {
      "xs": {
        "width": 160
      },
      "s": {
        "width": 400
      },
      "m": {
        "width": 750
      },
      "l": {
        "width": 1200
      },
      "xl": {
        "width": 2000
      }
    },
    "custom": {
      "hero_eyebrow": {
        "type": "text",
        "default": "Arquitetura e construção"
      },
      "hero_cta_text": {
        "type": "text",
        "default": "Conheça nossos projetos"
      },
      "hero_cta_url": {
        "type": "text",
        "default": "/projetos/"
      },
      "accent_color": {
        "type": "color",
        "default": "#b86b43"
      },
      "enable_motion": {
        "type": "boolean",
        "default": true
      }
    }
  },
  "scripts": {
    "test": "gscan ."
  }
}

Custom settings devem controlar escolhas pequenas: CTA, cor, estilo, liga/desliga. Não tente colocar toda a homepage em dezenas de campos curtos. Conteúdo editorial pertence a páginas ou posts.

default.hbs

<!doctype html>
<html lang="{{@site.locale}}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <link rel="preconnect" href="https://fonts.example.com" crossorigin>
    <link rel="stylesheet" href="{{asset "built/screen.css"}}">

    <style>
        :root {
            --color-accent: {{@custom.accent_color}};
        }
    </style>

    {{ghost_head}}
</head>
<body class="{{body_class}}">
    <a class="skip-link" href="#conteudo">Pular para o conteúdo</a>

    {{> "site-header"}}

    <main id="conteudo">
        {{{body}}}
    </main>

    {{> "site-footer"}}

    <script src="{{asset "built/main.js"}}" defer></script>
    {{ghost_foot}}
</body>
</html>

Nunca remova {{ghost_head}} e {{ghost_foot}}. Eles carregam metadados, integrações, Portal, code injection e recursos necessários do Ghost.

Cabeçalho

partials/site-header.hbs:

<header class="site-header" data-header>
    <a class="site-brand" href="{{@site.url}}" aria-label="{{@site.title}}">
        {{#if @site.logo}}
            <img src="{{@site.logo}}" alt="{{@site.title}}" width="180" height="48">
        {{else}}
            <span>{{@site.title}}</span>
        {{/if}}
    </a>

    <button
        class="menu-toggle"
        type="button"
        aria-expanded="false"
        aria-controls="site-navigation"
        data-menu-toggle>
        <span class="sr-only">Abrir menu</span>
        <span aria-hidden="true"></span>
        <span aria-hidden="true"></span>
    </button>

    <nav id="site-navigation" class="site-navigation" aria-label="Principal" data-menu>
        {{navigation}}
        <a class="button button-small" href="/contato/">Fale conosco</a>
    </nav>
</header>

O helper {{navigation}} usa o menu editado no Admin. Para markup totalmente próprio, use o partial customizado documentado pelo Ghost, mas comece com o helper oficial.

Rodapé

partials/site-footer.hbs:

<footer class="site-footer">
    <div class="footer-grid">
        <section>
            <h2>{{@site.title}}</h2>
            <p>{{@site.description}}</p>
        </section>

        <nav aria-label="Rodapé">
            {{navigation type="secondary"}}
        </nav>

        {{> "newsletter"}}
    </div>

    <p class="footer-legal">
        © <span data-current-year></span> {{@site.title}}.
        <a href="/privacidade/">Privacidade</a>
    </p>
</footer>

partials/newsletter.hbs:

<section class="newsletter">
    <h2>Receba novidades</h2>
    <p>Projetos, processos e ideias, sem excesso de e-mails.</p>
    <a class="button" href="#/portal/signup">Assinar newsletter</a>
</section>

O Portal preserva a integração nativa com Members e newsletters.

Construir a homepage

home.hbs é usado na raiz /. Ele pode consultar páginas e posts com {{#get}}.

{{!< default}}

<section class="hero" data-hero>
    <div class="hero-media" aria-hidden="true">
        <video autoplay muted loop playsinline poster="{{asset "images/hero-poster.jpg"}}">
            <source src="{{asset "videos/hero.webm"}}" type="video/webm">
            <source src="{{asset "videos/hero.mp4"}}" type="video/mp4">
        </video>
    </div>

    <div class="hero-overlay"></div>

    <div class="container hero-content">
        <p class="eyebrow" data-hero-item>{{@custom.hero_eyebrow}}</p>
        <h1 data-hero-item>
            Construímos espaços que permanecem.
        </h1>
        <p class="hero-summary" data-hero-item>
            Arquitetura, engenharia e execução reunidas num processo claro.
        </p>
        <a class="button" href="{{@custom.hero_cta_url}}" data-hero-item>
            {{@custom.hero_cta_text}}
        </a>
    </div>
</section>

<section class="section" aria-labelledby="servicos-titulo">
    <div class="container">
        <p class="eyebrow">O que fazemos</p>
        <h2 id="servicos-titulo">Serviços completos, sem ruído.</h2>

        <div class="card-grid">
            {{#get "posts" filter="tag:hash-servico" limit="6" include="tags"}}
                {{#foreach posts}}
                    {{> "service-card"}}
                {{else}}
                    <p>Cadastre posts com a tag interna #servico.</p>
                {{/foreach}}
            {{/get}}
        </div>
    </div>
</section>

<section class="section section-dark" aria-labelledby="projetos-titulo">
    <div class="container">
        <div class="section-heading">
            <div>
                <p class="eyebrow">Projetos selecionados</p>
                <h2 id="projetos-titulo">Trabalho que pode ser percorrido.</h2>
            </div>
            <a href="/projetos/">Ver todos</a>
        </div>

        <div class="project-grid">
            {{#get "posts" filter="tag:hash-projeto" limit="6" include="tags,authors"}}
                {{#foreach posts}}
                    {{> "project-card"}}
                {{else}}
                    <p>Cadastre posts com a tag interna #projeto.</p>
                {{/foreach}}
            {{/get}}
        </div>
    </div>
</section>

{{#get "pages" slug="inicio" limit="1"}}
    {{#foreach pages}}
        <section class="section page-section">
            <div class="container prose">
                {{content}}
            </div>
        </section>
    {{/foreach}}
{{/get}}

<section class="section cta-section">
    <div class="container">
        <p class="eyebrow">Vamos conversar</p>
        <h2>Seu próximo projeto começa com uma boa pergunta.</h2>
        <a class="button" href="/contato/">Falar com a equipe</a>
    </div>
</section>

O hero está no código para demonstrar direção de arte. Em produção, você pode mover título e texto para a página inicio, para custom settings ou para uma integração. Evite permitir que um editor troque livremente estrutura e classes.

Card de serviço

partials/service-card.hbs:

<article class="service-card" data-reveal>
    <a href="{{url}}">
        <span class="service-index" aria-hidden="true">{{@number}}</span>
        <h3>{{title}}</h3>
        <p>{{excerpt words="24"}}</p>
        <span class="text-link">Conhecer serviço</span>
    </a>
</article>

Card de projeto com imagem responsiva

partials/project-card.hbs:

<article class="project-card" data-reveal>
    <a href="{{url}}">
        {{#if feature_image}}
            <picture class="project-image">
                <source
                    srcset="{{img_url feature_image size="m" format="webp"}} 750w,
                            {{img_url feature_image size="l" format="webp"}} 1200w,
                            {{img_url feature_image size="xl" format="webp"}} 2000w"
                    sizes="(min-width: 900px) 50vw, 100vw"
                    type="image/webp">
                <img
                    src="{{img_url feature_image size="l"}}"
                    alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
                    loading="lazy"
                    width="1200"
                    height="800">
            </picture>
        {{/if}}

        <div class="project-card-body">
            <h3>{{title}}</h3>
            <p>{{excerpt words="20"}}</p>
        </div>
    </a>
</article>

Cadastre image_sizes no package.json; sem isso, os tamanhos nomeados não serão gerados.

Blog e páginas individuais

index.hbs

{{!< default}}

<header class="archive-header">
    <div class="container">
        <p class="eyebrow">Ideias e processos</p>
        <h1>Blog</h1>
        <p>Notas sobre arquitetura, materiais, cidades e construção.</p>
    </div>
</header>

<section class="section">
    <div class="container post-grid">
        {{#foreach posts}}
            <article class="{{post_class}}">
                <a href="{{url}}">
                    {{#if feature_image}}
                        <img
                            src="{{img_url feature_image size="m"}}"
                            alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
                            loading="lazy">
                    {{/if}}
                    <p class="post-meta">
                        <time datetime="{{date format="YYYY-MM-DD"}}">
                            {{date format="DD MMM YYYY"}}
                        </time>
                    </p>
                    <h2>{{title}}</h2>
                    <p>{{excerpt words="28"}}</p>
                </a>
            </article>
        {{else}}
            <p>Nenhum artigo publicado.</p>
        {{/foreach}}
    </div>

    {{pagination}}
</section>

post.hbs

{{!< default}}

{{#post}}
<article class="{{post_class}}">
    <header class="post-header container">
        <p class="post-meta">
            {{primary_tag}}
            <span aria-hidden="true">·</span>
            <time datetime="{{date format="YYYY-MM-DD"}}">
                {{date format="DD MMMM YYYY"}}
            </time>
            <span aria-hidden="true">·</span>
            {{reading_time}}
        </p>

        <h1>{{title}}</h1>

        {{#if custom_excerpt}}
            <p class="post-deck">{{custom_excerpt}}</p>
        {{/if}}

        {{#if feature_image}}
            <figure class="post-feature">
                <img
                    src="{{img_url feature_image size="xl"}}"
                    alt="{{feature_image_alt}}"
                    width="2000"
                    height="1125">
                {{#if feature_image_caption}}
                    <figcaption>{{feature_image_caption}}</figcaption>
                {{/if}}
            </figure>
        {{/if}}
    </header>

    <div class="post-content kg-canvas">
        {{content}}
    </div>

    <footer class="post-footer container">
        {{tags separator=" · "}}
        {{#primary_author}}
            <section class="author-box">
                {{#if profile_image}}
                    <img src="{{img_url profile_image size="s"}}" alt="">
                {{/if}}
                <div>
                    <h2>{{name}}</h2>
                    {{#if bio}}<p>{{bio}}</p>{{/if}}
                </div>
            </section>
        {{/primary_author}}
    </footer>
</article>
{{/post}}

{{content}} renderiza cards do editor. O CSS precisa cobrir classes kg-* de imagens, galerias, vídeos, áudio, arquivos, embeds, bookmarks, botões e toggles. Com card_assets: true, o Ghost inclui assets padrão para cards compatíveis, mas a tipografia continua sendo responsabilidade do tema.

page.hbs

{{!< default}}

{{#post}}
<article class="{{post_class}} page-shell">
    <header class="page-header container">
        <h1>{{title}}</h1>
        {{#if custom_excerpt}}
            <p>{{custom_excerpt}}</p>
        {{/if}}
    </header>

    <div class="page-content kg-canvas">
        {{content}}
    </div>
</article>
{{/post}}

Uma página sobre usa automaticamente page-sobre.hbs se o arquivo existir. A ordem de resolução é:

page-sobre.hbs -> page.hbs -> post.hbs

Use templates por slug quando a página precisa de estrutura visual própria.

Rotas comerciais

Por padrão, o Ghost usa a homepage como coleção de posts. Se deseja:

/              -> homepage institucional
/blog/         -> artigos
/blog/slug/    -> artigo
/projetos/     -> cases
/projetos/slug -> case

Crie routes.yaml:

routes:
  /:
    template: home

collections:
  /blog/:
    permalink: /blog/{slug}/
    template: index
    filter: tag:blog

  /projetos/:
    permalink: /projetos/{slug}/
    template: projetos
    filter: tag:hash-projeto

taxonomies:
  tag: /assunto/{slug}/
  author: /autor/{slug}/

Faça download e upload em Settings > Labs ou seção equivalente da versão. Ao editar diretamente content/settings/routes.yaml, reinicie o Ghost.

Uma publicação só pode pertencer a uma collection principal. Planeje filtros para evitar que o mesmo post fique disputado por duas collections.

CSS: sistema visual antes de efeitos

src/css/screen.css:

:root {
    --color-bg: #f3f0e8;
    --color-text: #171714;
    --color-muted: #65645e;
    --color-dark: #171714;
    --color-light: #fffdf7;
    --color-accent: #b86b43;
    --font-display: "Fraunces", Georgia, serif;
    --font-body: "Inter", system-ui, sans-serif;
    --container: 78rem;
    --gutter: clamp(1.25rem, 4vw, 4rem);
    --section-space: clamp(5rem, 10vw, 10rem);
}

*,
*::before,
*::after {
    box-sizing: border-box;
}

html {
    color-scheme: light;
    scroll-behavior: smooth;
}

body {
    margin: 0;
    color: var(--color-text);
    background: var(--color-bg);
    font-family: var(--font-body);
    line-height: 1.6;
}

img,
video {
    display: block;
    max-width: 100%;
    height: auto;
}

a {
    color: inherit;
}

:focus-visible {
    outline: 3px solid var(--color-accent);
    outline-offset: 4px;
}

.container {
    width: min(var(--container), calc(100% - 2 * var(--gutter)));
    margin-inline: auto;
}

.section {
    padding-block: var(--section-space);
}

.section-dark {
    color: var(--color-light);
    background: var(--color-dark);
}

.hero {
    position: relative;
    display: grid;
    min-height: 100svh;
    align-items: end;
    overflow: clip;
    color: white;
}

.hero-media,
.hero-overlay {
    position: absolute;
    inset: 0;
}

.hero-media video {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.hero-overlay {
    background:
        linear-gradient(to top, rgb(0 0 0 / 75%), transparent 65%),
        linear-gradient(to right, rgb(0 0 0 / 30%), transparent);
}

.hero-content {
    position: relative;
    z-index: 1;
    padding-block: 10rem 4rem;
}

.hero h1 {
    max-width: 12ch;
    margin: 0;
    font-family: var(--font-display);
    font-size: clamp(3.5rem, 9vw, 9rem);
    font-weight: 500;
    line-height: 0.9;
    letter-spacing: -0.055em;
}

.kg-canvas {
    display: grid;
    grid-template-columns:
        [full-start] minmax(var(--gutter), 1fr)
        [wide-start] minmax(0, 12rem)
        [main-start] min(42rem, calc(100% - 2 * var(--gutter)))
        [main-end] minmax(0, 12rem)
        [wide-end] minmax(var(--gutter), 1fr)
        [full-end];
}

.kg-canvas > * {
    grid-column: main;
}

.kg-canvas > .kg-width-wide {
    grid-column: wide;
}

.kg-canvas > .kg-width-full {
    grid-column: full;
}

.skip-link {
    position: fixed;
    top: 1rem;
    left: 1rem;
    z-index: 1000;
    transform: translateY(-200%);
    padding: 0.75rem 1rem;
    color: white;
    background: black;
}

.skip-link:focus {
    transform: none;
}

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        scroll-behavior: auto !important;
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

Construa primeiro hierarquia, tipografia, contraste, espaçamento, responsividade e estados. Animação não conserta layout genérico.

JavaScript e GSAP

Instale:

npm install gsap

Use um bundler simples, como esbuild:

npm install --save-dev esbuild
npx esbuild src/js/main.js \
  --bundle \
  --minify \
  --outfile=assets/built/main.js

src/js/main.js:

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

const reduceMotion = window.matchMedia(
    "(prefers-reduced-motion: reduce)"
).matches;

const menuButton = document.querySelector("[data-menu-toggle]");
const menu = document.querySelector("[data-menu]");

if (menuButton && menu) {
    menuButton.addEventListener("click", () => {
        const open = menuButton.getAttribute("aria-expanded") === "true";
        menuButton.setAttribute("aria-expanded", String(!open));
        menu.toggleAttribute("data-open", !open);
    });
}

const year = document.querySelector("[data-current-year]");
if (year) {
    year.textContent = String(new Date().getFullYear());
}

if (!reduceMotion) {
    const heroItems = document.querySelectorAll("[data-hero-item]");

    if (heroItems.length) {
        gsap.from(heroItems, {
            opacity: 0,
            y: 48,
            duration: 1,
            stagger: 0.12,
            ease: "power3.out"
        });
    }

    gsap.utils.toArray("[data-reveal]").forEach((element) => {
        gsap.from(element, {
            opacity: 0,
            y: 36,
            duration: 0.8,
            ease: "power2.out",
            scrollTrigger: {
                trigger: element,
                start: "top 85%",
                once: true
            }
        });
    });
}

Boas regras:

  • anime transform e opacity;
  • não esconda conteúdo essencial esperando JavaScript;
  • respeite prefers-reduced-motion;
  • não substitua scroll nativo sem motivo;
  • limite efeitos que rodam durante scroll;
  • teste em aparelho intermediário;
  • carregue vídeo com poster;
  • pause mídia fora de visão quando necessário;
  • não use animação para atrasar acesso ao conteúdo.

GSAP 3.13 ou superior e seus plugins estão disponíveis pelo npm atual. Registre explicitamente plugins para evitar remoção por tree shaking.

Tailwind CSS como opção

Tailwind não é necessário. CSS próprio costuma produzir um tema com identidade mais consciente. Use Tailwind quando a equipe realmente trabalha melhor com utilities.

Para Tailwind 4:

npm install --save-dev tailwindcss @tailwindcss/cli

src/css/screen.css:

@import "tailwindcss";
@source "../../**/*.hbs";

@theme {
    --color-brand: #b86b43;
    --font-display: "Fraunces", Georgia, serif;
}

Build:

npx @tailwindcss/cli \
  -i ./src/css/screen.css \
  -o ./assets/built/screen.css \
  --watch

Não construa classes dinamicamente:

{{! Evite }}
<div class="text-{{color}}-600"></div>

O scanner não consegue garantir a geração. Use nomes completos ou uma safelist controlada.

Formulário de contato com n8n

page-contato.hbs:

{{!< default}}

{{#post}}
<section class="section">
    <div class="container contact-layout">
        <header>
            <p class="eyebrow">Contato</p>
            <h1>{{title}}</h1>
            <div class="prose">{{content}}</div>
        </header>

        <form
            class="contact-form"
            action="https://automacao.seudominio.com/webhook/contato-publico"
            method="post"
            data-contact-form>

            <div class="form-field">
                <label for="name">Nome</label>
                <input id="name" name="name" autocomplete="name" required>
            </div>

            <div class="form-field">
                <label for="email">E-mail</label>
                <input id="email" name="email" type="email" autocomplete="email" required>
            </div>

            <div class="form-field">
                <label for="company">Empresa</label>
                <input id="company" name="company" autocomplete="organization">
            </div>

            <div class="form-field">
                <label for="message">Como podemos ajudar?</label>
                <textarea id="message" name="message" rows="7" required></textarea>
            </div>

            <div class="honeypot" aria-hidden="true">
                <label for="website">Website</label>
                <input id="website" name="website" tabindex="-1" autocomplete="off">
            </div>

            <input type="hidden" name="source" value="site-atelier-norte">

            <label class="consent">
                <input type="checkbox" name="privacy" value="accepted" required>
                Li e aceito a política de privacidade.
            </label>

            <button class="button" type="submit">Enviar mensagem</button>
            <p role="status" aria-live="polite" data-form-status></p>
        </form>
    </div>
</section>
{{/post}}

No n8n:

  1. crie um Webhook POST;
  2. valide tamanho e formato dos campos;
  3. descarte submissões com honeypot preenchido;
  4. aplique rate limit no proxy;
  5. use CAPTCHA apenas se o spam justificar;
  6. normalize texto, mas preserve a mensagem original;
  7. envie para CRM ou e-mail;
  8. não registre dados pessoais em logs excessivos;
  9. retorne JSON e HTTP 2xx;
  10. configure CORS apenas para o domínio do site.

Não coloque credenciais do n8n no JavaScript. O endpoint público deve ter autorização limitada ao ato de enviar um formulário. Proteja o editor e a API administrativa do n8n separadamente.

SEO no tema Ghost

O Ghost já gera grande parte do SEO por meio de {{ghost_head}}:

  • title e meta description;
  • canonical;
  • Open Graph;
  • Twitter cards;
  • JSON-LD;
  • RSS;
  • referências necessárias.

Seu trabalho:

  • manter apenas um h1;
  • usar landmarks e headings coerentes;
  • configurar meta title e description no Admin;
  • fornecer alt text;
  • não bloquear crawl de assets essenciais;
  • otimizar imagens;
  • evitar conteúdo duplicado;
  • criar redirects ao alterar URLs;
  • testar canonical e compartilhamento;
  • manter links reais em vez de navegação dependente de JavaScript.

Não duplique manualmente tags que o Ghost já emite. Verifique o HTML final antes de adicionar plugins ou code injection.

Busca

O Ghost possui helpers e recursos próprios para busca em temas atuais. Para um site pequeno, a busca do Ghost ou um índice leve no navegador é suficiente. Para milhares de itens, filtros facetados ou ranking customizado, considere Pagefind em saída estática ou um serviço de busca.

Não envie toda a Content API ao navegador em cada visita.

Membros, newsletter e conteúdo pago

No tema tradicional, Ghost protege conteúdo no servidor. Você pode usar:

{{#if @member}}
    <p>Olá, {{@member.firstname}}.</p>
{{else}}
    <a href="#/portal/signin">Entrar</a>
{{/if}}

E:

{{#if access}}
    {{content}}
{{else}}
    <a href="#/portal/signup">Assine para continuar</a>
{{/if}}

Essa integração é uma vantagem forte do tema nativo. Em headless, reproduzir login, sessão e gating com o mesmo nível de segurança exige trabalho adicional. Se memberships são centrais, prefira o tema Ghost até provar que o headless atende todo o fluxo.

Validar, empacotar e instalar o tema

Instale GScan:

npm install -g gscan

Valide:

gscan .

Gere assets:

npx esbuild src/js/main.js \
  --bundle \
  --minify \
  --outfile=assets/built/main.js

Se usar CSS sem compilação:

cp src/css/screen.css assets/built/screen.css

Crie o ZIP com o conteúdo do tema na raiz do arquivo, não com uma pasta extra inesperada:

zip -r atelier-norte.zip . \
  -x "node_modules/*" \
  -x ".git/*" \
  -x "src/*" \
  -x "*.zip"

Teste o ZIP:

gscan -z atelier-norte.zip

No Ghost Admin:

  1. abra Settings > Design;
  2. escolha trocar ou instalar tema;
  3. envie o ZIP;
  4. leia os avisos;
  5. ative;
  6. envie routes.yaml, se necessário;
  7. confira custom settings;
  8. faça smoke test.

Mantenha o tema anterior instalado para rollback rápido.

Arquitetura B: Ghost headless com Astro

Fluxo

Ghost Admin em cms.seudominio.com
        ↓
Content API somente leitura
        ↓
Astro
        ↓
HTML estático ou servidor
        ↓
www.seudominio.com

O que continua no Ghost

  • editor;
  • posts e páginas;
  • tags e autores;
  • imagens;
  • conteúdo publicado;
  • SEO editorial digitado pelos autores;
  • newsletters;
  • webhooks;
  • Members como base, se você integrar a experiência.

O que passa para o Astro

  • rotas públicas;
  • templates;
  • layout;
  • SEO efetivamente emitido;
  • RSS e sitemap;
  • paginação;
  • busca;
  • imagens e otimização;
  • cache;
  • redirects;
  • erros;
  • analytics;
  • formulários;
  • preview;
  • integração de Members no frontend.

Preparar o Ghost headless

Use:

CMS/Admin: https://cms.seudominio.com
Site:      https://www.seudominio.com

No Ghost Admin:

  1. vá a Settings > Advanced > Integrations;
  2. crie Frontend Astro;
  3. copie a Content API URL;
  4. copie a Content API Key;
  5. não copie a Admin API Key para o frontend;
  6. registre posteriormente o webhook de deploy.

A Content API Key expõe somente conteúdo público e é considerada segura para clientes. Ainda assim, variável de ambiente evita acoplamento e facilita rotação. A Admin API Key é segredo real e nunca deve ir para navegador, repositório ou variável PUBLIC_*.

Criar o projeto Astro

npm create astro@latest atelier-norte-web
cd atelier-norte-web
npm install
npm install @tryghost/content-api

Estrutura:

atelier-norte-web/
├── public/
├── src/
│   ├── components/
│   │   ├── Header.astro
│   │   ├── Footer.astro
│   │   └── ProjectCard.astro
│   ├── layouts/
│   │   └── BaseLayout.astro
│   ├── lib/
│   │   └── ghost.js
│   ├── pages/
│   │   ├── blog/
│   │   │   ├── [slug].astro
│   │   │   └── index.astro
│   │   ├── index.astro
│   │   ├── contato.astro
│   │   └── rss.xml.js
│   └── styles/
│       └── global.css
├── .env
├── .env.example
├── astro.config.mjs
└── package.json

Variáveis

.env:

GHOST_URL=https://cms.seudominio.com
GHOST_CONTENT_API_KEY=sua_content_api_key

.env.example:

GHOST_URL=https://cms.example.com
GHOST_CONTENT_API_KEY=

.gitignore:

.env
.env.*
!.env.example
dist/
node_modules/

Configuração do Astro

astro.config.mjs:

import { defineConfig } from "astro/config";

export default defineConfig({
    site: "https://www.seudominio.com",
    trailingSlash: "always"
});

Definir site permite gerar canonical, sitemap e URLs absolutas corretamente.

Cliente do Ghost

src/lib/ghost.js:

import GhostContentAPI from "@tryghost/content-api";

const url = import.meta.env.GHOST_URL;
const key = import.meta.env.GHOST_CONTENT_API_KEY;

if (!url || !key) {
    throw new Error(
        "Defina GHOST_URL e GHOST_CONTENT_API_KEY no ambiente."
    );
}

export const ghost = new GhostContentAPI({
    url,
    key,
    version: "v6.0"
});

export async function getPosts(options = {}) {
    return ghost.posts.browse({
        limit: 20,
        include: "tags,authors",
        order: "published_at desc",
        ...options
    });
}

export async function getPost(slug) {
    return ghost.posts.read(
        { slug },
        { include: "tags,authors", formats: ["html", "plaintext"] }
    );
}

export async function getPages(options = {}) {
    return ghost.pages.browse({
        limit: 100,
        include: "tags,authors",
        ...options
    });
}

export async function getPage(slug) {
    return ghost.pages.read(
        { slug },
        { include: "tags,authors", formats: ["html", "plaintext"] }
    );
}

Ghost 6 retorna no máximo 100 itens por página. Para um site maior, implemente paginação:

export async function getAllPosts(options = {}) {
    const all = [];
    let page = 1;
    let pages = 1;

    do {
        const batch = await ghost.posts.browse({
            limit: 100,
            page,
            include: "tags,authors",
            ...options
        });

        all.push(...batch);
        pages = batch.meta.pagination.pages;
        page += 1;
    } while (page <= pages);

    return all;
}

Não use limit: "all": no Ghost 6 isso retorna no máximo 100.

Layout com SEO

src/layouts/BaseLayout.astro:

---
import "../styles/global.css";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";

const {
    title,
    description = "Arquitetura, engenharia e execução.",
    image,
    canonical = Astro.url,
    type = "website",
    publishedTime
} = Astro.props;

const absoluteImage = image
    ? new URL(image, Astro.site).toString()
    : new URL("/social-default.jpg", Astro.site).toString();
---

<!doctype html>
<html lang="pt-BR">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width" />

        <title>{title}</title>
        <meta name="description" content={description} />
        <link rel="canonical" href={canonical} />

        <meta property="og:type" content={type} />
        <meta property="og:title" content={title} />
        <meta property="og:description" content={description} />
        <meta property="og:url" content={canonical} />
        <meta property="og:image" content={absoluteImage} />

        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content={title} />
        <meta name="twitter:description" content={description} />
        <meta name="twitter:image" content={absoluteImage} />

        {publishedTime && (
            <meta property="article:published_time" content={publishedTime} />
        )}

        <link rel="alternate" type="application/rss+xml" href="/rss.xml" />
    </head>
    <body>
        <a class="skip-link" href="#content">Pular para o conteúdo</a>
        <Header />
        <main id="content">
            <slot />
        </main>
        <Footer />
    </body>
</html>

No headless, {{ghost_head}} não existe. Você precisa emitir manualmente meta title, description, canonical, Open Graph, Twitter, JSON-LD, RSS e outros elementos.

Homepage no Astro

src/pages/index.astro:

---
import BaseLayout from "../layouts/BaseLayout.astro";
import ProjectCard from "../components/ProjectCard.astro";
import { getPage, getPosts } from "../lib/ghost.js";

const [home, projects, services] = await Promise.all([
    getPage("inicio"),
    getPosts({ filter: "tag:hash-projeto", limit: 6 }),
    getPosts({ filter: "tag:hash-servico", limit: 6 })
]);
---

<BaseLayout
    title={home.meta_title || home.title}
    description={home.meta_description || home.excerpt}
    image={home.og_image || home.feature_image}>

    <section class="hero">
        <div class="container">
            <p class="eyebrow">Arquitetura e construção</p>
            <h1>{home.title}</h1>
            <p>{home.excerpt}</p>
            <a class="button" href="/projetos/">Conheça nossos projetos</a>
        </div>
    </section>

    <section class="section">
        <div class="container">
            <h2>Serviços</h2>
            <div class="card-grid">
                {services.map((service) => (
                    <article>
                        <h3>{service.title}</h3>
                        <p>{service.excerpt}</p>
                    </article>
                ))}
            </div>
        </div>
    </section>

    <section class="section section-dark">
        <div class="container">
            <h2>Projetos</h2>
            <div class="project-grid">
                {projects.map((project) => (
                    <ProjectCard project={project} />
                ))}
            </div>
        </div>
    </section>

    <section class="ghost-content" set:html={home.html} />
</BaseLayout>

set:html insere o HTML entregue pelo Ghost. Isso é aceitável para conteúdo produzido por editores confiáveis. Se autores não confiáveis puderem inserir HTML, aplique sanitização e uma política de conteúdo.

Arquivo do blog

src/pages/blog/index.astro:

---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getPosts } from "../../lib/ghost.js";

const posts = await getPosts({
    filter: "tag:blog",
    limit: 24
});
---

<BaseLayout
    title="Blog | Atelier Norte"
    description="Artigos sobre arquitetura, materiais e cidades.">
    <header class="archive-header container">
        <h1>Blog</h1>
    </header>

    <section class="container post-grid">
        {posts.map((post) => (
            <article>
                <a href={`/blog/${post.slug}/`}>
                    {post.feature_image && (
                        <img
                            src={post.feature_image}
                            alt={post.feature_image_alt || post.title}
                            loading="lazy"
                        />
                    )}
                    <h2>{post.title}</h2>
                    <p>{post.excerpt}</p>
                </a>
            </article>
        ))}
    </section>
</BaseLayout>

Para mais de 24 posts, crie páginas /blog/2/, /blog/3/ usando meta.pagination, ou gere todos os caminhos no build.

Página dinâmica do artigo

src/pages/blog/[slug].astro:

---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getAllPosts } from "../../lib/ghost.js";

export async function getStaticPaths() {
    const posts = await getAllPosts({
        filter: "tag:blog",
        formats: ["html", "plaintext"]
    });

    return posts.map((post) => ({
        params: { slug: post.slug },
        props: { post }
    }));
}

const { post } = Astro.props;

const description =
    post.meta_description ||
    post.custom_excerpt ||
    post.excerpt;
---

<BaseLayout
    title={post.meta_title || `${post.title} | Atelier Norte`}
    description={description}
    image={post.og_image || post.feature_image}
    canonical={post.canonical_url || Astro.url}
    type="article"
    publishedTime={post.published_at}>

    <article>
        <header class="post-header container">
            <p>
                <time datetime={post.published_at}>
                    {new Date(post.published_at).toLocaleDateString("pt-BR")}
                </time>
            </p>
            <h1>{post.title}</h1>
            {post.custom_excerpt && <p>{post.custom_excerpt}</p>}
            {post.feature_image && (
                <img
                    src={post.feature_image}
                    alt={post.feature_image_alt || ""}
                    width="1600"
                    height="900"
                />
            )}
        </header>

        <div class="post-content kg-canvas" set:html={post.html} />
    </article>
</BaseLayout>

No modo estático, getStaticPaths() determina quais páginas serão geradas no build. Um novo post não existe no site público até novo build.

Páginas institucionais dinâmicas

Se quiser gerar /sobre/, /servicos/ e outras páginas a partir do Ghost:

src/pages/[slug].astro:

---
import BaseLayout from "../layouts/BaseLayout.astro";
import { getPages } from "../lib/ghost.js";

export async function getStaticPaths() {
    const pages = await getPages();

    return pages
        .filter((page) => page.slug !== "inicio")
        .map((page) => ({
            params: { slug: page.slug },
            props: { page }
        }));
}

const { page } = Astro.props;
---

<BaseLayout
    title={page.meta_title || page.title}
    description={page.meta_description || page.excerpt}
    image={page.og_image || page.feature_image}>
    <article>
        <header class="page-header container">
            <h1>{page.title}</h1>
        </header>
        <div class="page-content kg-canvas" set:html={page.html} />
    </article>
</BaseLayout>

Rotas estáticas como /contato/ têm prioridade sobre [slug].astro, mas documente conflitos. Não permita que um editor crie página com slug reservado como api, blog, rss.xml ou admin.

GSAP no Astro

Instale:

npm install gsap

Num componente:

<section class="hero" data-astro-hero>
    <h1>Construímos espaços que permanecem.</h1>
</section>

<script>
    import { gsap } from "gsap";

    const reduceMotion = window.matchMedia(
        "(prefers-reduced-motion: reduce)"
    ).matches;

    if (!reduceMotion) {
        gsap.from("[data-astro-hero] h1", {
            opacity: 0,
            y: 48,
            duration: 1,
            ease: "power3.out"
        });
    }
</script>

O Astro agrupa scripts automaticamente. Se você adicionar navegação client-side posteriormente, faça cleanup de animações e ScrollTriggers ao trocar de página.

Static, SSR ou híbrido

Static

Ghost -> build -> arquivos HTML -> CDN

Vantagens:

  • velocidade;
  • baixo custo;
  • superfície pequena no site público;
  • tolerância a indisponibilidade temporária do Ghost depois do build.

Limitações:

  • publicação exige rebuild;
  • preview de rascunho é mais difícil;
  • milhares de páginas aumentam o build;
  • conteúdo agendado exige disparo no horário correto.

SSR

Visitante -> servidor Astro -> Ghost API -> HTML

Vantagens:

  • conteúdo aparece imediatamente;
  • rotas dinâmicas;
  • lógica por requisição.

Limitações:

  • frontend depende da API em runtime;
  • precisa de adapter e servidor;
  • cache, timeout e fallback são sua responsabilidade;
  • há dois serviços ativos.

Híbrido

Pré-renderize páginas estáveis e renderize sob demanda apenas áreas dinâmicas. É útil quando o catálogo muda muito, mas o conteúdo institucional quase não muda.

Para o site do anexo, se optar por headless, comece estático e use webhook. Não adote SSR apenas para imitar o comportamento que o tema Ghost já entregaria com menos partes.

Rebuild automático por webhook

Ghost envia webhooks em eventos como:

  • site.changed;
  • post.published;
  • post.published.edited;
  • post.unpublished;
  • page.published;
  • page.published.edited;
  • alterações em tags.

No provedor do Astro, crie um deploy hook. Depois:

  1. abra a integração Frontend Astro;
  2. clique em adicionar webhook;
  3. escolha site.changed para simplicidade ou eventos específicos;
  4. cole a URL secreta do deploy hook;
  5. publique um post de teste;
  6. confira resposta 2xx;
  7. acompanhe o build;
  8. confirme que o novo conteúdo entrou.

Não exponha a URL do deploy hook em repositório público. Quem a possui pode provocar builds e consumir sua cota.

Debounce

site.changed pode disparar várias vezes durante edições. Para um site movimentado, envie primeiro a um endpoint próprio ou n8n:

Ghost webhook
    ↓
n8n recebe e espera 30–60 segundos
    ↓
agrupa eventos
    ↓
chama deploy hook uma vez

Imponha limite e autenticação no fluxo para evitar abuso.

RSS e sitemap no Astro

Instale:

npm install @astrojs/rss @astrojs/sitemap

Adicione sitemap:

import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
    site: "https://www.seudominio.com",
    integrations: [sitemap()]
});

src/pages/rss.xml.js:

import rss from "@astrojs/rss";
import { getPosts } from "../lib/ghost.js";

export async function GET(context) {
    const posts = await getPosts({
        filter: "tag:blog",
        limit: 100
    });

    return rss({
        title: "Atelier Norte",
        description: "Arquitetura, materiais e cidades.",
        site: context.site,
        items: posts.map((post) => ({
            title: post.title,
            description: post.excerpt,
            pubDate: new Date(post.published_at),
            link: `/blog/${post.slug}/`
        }))
    });
}

Se houver mais de 100 posts, pagine a API antes de montar o feed ou limite conscientemente o feed aos mais recentes.

Preview no headless

A Content API entrega conteúdo publicado. Um editor pode usar o preview nativo do Ghost no domínio do CMS, mas esse preview não reproduz exatamente o frontend Astro.

Alternativas:

  1. aceitar o preview nativo para revisão de texto;
  2. manter um ambiente staging com conteúdo publicado de teste;
  3. criar preview autenticado usando Admin API no servidor;
  4. usar SSR com token temporário e rota protegida.

Nunca exponha Admin API Key no navegador. Um preview customizado precisa:

  • autenticação;
  • token curto;
  • proteção contra indexação;
  • validação de slug;
  • logs mínimos;
  • acesso server-side à Admin API;
  • expiração e revogação.

Para equipe pequena, o staging editorial é mais simples.

Members no headless

No tema Ghost, o servidor conhece a sessão do membro e controla access. Num frontend headless, a Content API pública não se transforma automaticamente nessa sessão.

Você pode incorporar o Portal atual usando o script indicado pelo Ghost 6 e a Content API, mas deve testar:

  • signup;
  • magic link;
  • signin;
  • logout;
  • Stripe;
  • retorno ao domínio público;
  • cookies entre cms. e www.;
  • conteúdo restrito;
  • clientes de e-mail;
  • canonical e redirects.

Se o produto depende de conteúdo pago seguro, tema Ghost é a opção simples e robusta. Não monte paywall apenas escondendo HTML com JavaScript.

Imagens no headless

O Ghost entrega URLs absolutas de imagem e srcset dentro de post.html. Para feature images:

  • use o URL do Ghost diretamente;
  • defina width e height quando conhecidos;
  • use loading="lazy" fora da dobra;
  • não aplique duas pipelines de compressão sem medir;
  • garanta que o domínio de imagens aceita acesso público;
  • preserve alt text e caption;
  • atualize URLs ao mudar domínio do CMS.

Se o Ghost ficar em rede privada, o frontend pode buscar dados no build, mas as imagens referenciadas pelo navegador também precisam estar acessíveis ou ser copiadas para storage/CDN.

Formulários no Astro

As opções são:

  • endpoint do provedor;
  • API route do Astro em SSR;
  • serviço de formulários;
  • n8n atrás de um endpoint controlado.

Em site estático, o navegador envia diretamente ao endpoint. Não inclua secrets. Valide no servidor, aplique rate limit, honeypot e política de privacidade.

Deploy recomendado

Tema Ghost

VPS Ubuntu
├── Nginx
├── Ghost
├── MySQL
└── conteúdo e tema

Ou:

Ghost(Pro)
└── tema personalizado

Headless estático

VPS ou Ghost(Pro)
└── Ghost em cms.seudominio.com

Cloudflare Pages, Netlify, Vercel ou servidor próprio
└── Astro em www.seudominio.com

Headless self-hosted

Servidor 1
└── Ghost + MySQL

Servidor 2 ou mesmo host
└── build Astro + Nginx/Caddy

Separar hosts melhora isolamento, mas aumenta operação. No início, não crie dois servidores só por estética arquitetural.

Segurança

Ghost

  • HTTPS obrigatório;
  • Admin sob HTTPS;
  • e-mail transacional funcional;
  • 2FA para staff;
  • updates regulares;
  • MySQL não exposto;
  • firewall;
  • SSH por chave;
  • backups fora do host;
  • domínio administrativo separado, se necessário;
  • menor número possível de staff admins;
  • revisão de integrações e webhooks.

Tema

  • sem secrets em JavaScript;
  • scripts externos com origem controlada;
  • evitar HTML não confiável;
  • formulários validados no servidor;
  • dependências mínimas;
  • links externos com comportamento consciente;
  • CSP testada antes de aplicar;
  • nenhum endpoint administrativo exposto pelo tema.

Headless

  • Content API Key pode ser pública, mas Admin API Key não;
  • deploy hooks são secretos;
  • variáveis sem prefixo público;
  • CMS atualizado mesmo que esteja “nos bastidores”;
  • CORS restrito;
  • timeouts e fallback na API;
  • preview autenticado;
  • logs sem tokens;
  • proteção contra build storm;
  • dependências do frontend atualizadas.

Desempenho

Orçamento sugerido

RecursoMeta inicial
CSS comprimidomenos de 100 KB
JavaScript inicialmenos de 150 KB, idealmente menos
hero postermenos de 250 KB
vídeo inicialcarregar conscientemente, não em rede lenta
fontes1–2 famílias, poucos pesos
LCPaté 2,5 s em condições reais
CLSaté 0,1
INPaté 200 ms

São metas, não garantias. Meça com Lighthouse e WebPageTest, mas priorize experiência real.

Otimizações com maior impacto

  1. imagem correta;
  2. largura e altura reservadas;
  3. fontes locais ou bem carregadas;
  4. CSS crítico pequeno;
  5. JavaScript só quando necessário;
  6. animações simples;
  7. cache;
  8. CDN;
  9. poster para vídeo;
  10. nenhuma biblioteca usada apenas para um efeito trivial.

Lenis, Swiper e Three.js são ferramentas, não ingredientes obrigatórios. Cada uma adiciona código, bugs e casos de acessibilidade.

Acessibilidade

Teste:

  • navegação só por teclado;
  • foco visível;
  • menu mobile com aria-expanded;
  • contraste;
  • zoom de 200%;
  • leitor de tela;
  • headings;
  • alt text;
  • formulários e mensagens de erro;
  • prefers-reduced-motion;
  • vídeo sem som automático;
  • controles para mídia com áudio;
  • links distinguíveis;
  • largura de texto;
  • conteúdo sem JavaScript.

Uma experiência “cinematográfica” que impede leitura, seleção de texto ou navegação por teclado é um site pior, não mais sofisticado.

Testes antes do lançamento

Conteúdo

  • títulos curtos e longos
  • posts sem imagem
  • imagens horizontal e vertical
  • galerias
  • embeds
  • vídeo e áudio
  • múltiplos autores
  • tags
  • rascunho e agendamento
  • página vazia
  • caracteres acentuados

Layout

  • 320 px
  • 390 px
  • tablet
  • notebook
  • monitor grande
  • landscape mobile
  • zoom de 200%

Funcional

  • menu
  • formulário
  • newsletter
  • login de membro
  • busca
  • paginação
  • RSS
  • sitemap
  • 404
  • redirects
  • webhook/rebuild

Técnico

  • GScan sem erros fatais
  • console sem erros
  • links sem 404
  • canonical correto
  • Open Graph
  • structured data
  • robots.txt
  • TLS
  • headers
  • backup
  • rollback

Backup e manutenção

Tema Ghost

Guarde:

  • repositório Git do tema;
  • ZIP de cada release;
  • routes.yaml;
  • export JSON do Ghost;
  • diretório content;
  • banco MySQL;
  • configuração do servidor;
  • credenciais em cofre separado.

Versione releases:

1.0.0 lançamento
1.0.1 correção
1.1.0 nova seção
2.0.0 mudança incompatível

Headless

Além do Ghost:

  • repositório Astro;
  • variáveis documentadas;
  • deploy hooks;
  • configuração DNS;
  • redirects;
  • cache;
  • analytics;
  • dependências;
  • processo de build;
  • ambiente staging.

Atualização

Antes de atualizar Ghost:

  1. leia breaking changes;
  2. exporte conteúdo;
  3. faça backup do banco e content;
  4. rode GScan;
  5. teste tema e Content API em staging;
  6. confira paginação;
  7. atualize;
  8. teste publicação, members, newsletter e webhooks;
  9. monitore logs.

Ghost 5 chegou ao fim do suporte em janeiro de 2026. Um site novo deve usar Ghost 6 atual e suportado.

Diagnóstico

Alterei .hbs, mas nada mudou

Em produção, templates são cacheados:

ghost restart

Em desenvolvimento local, alterações existentes recarregam; arquivos novos podem exigir restart.

Tema não envia

gscan .
gscan -z atelier-norte.zip

Verifique:

  • package.json válido;
  • index.hbs;
  • post.hbs;
  • API engine;
  • arquivo ZIP com estrutura correta;
  • helpers fechados;
  • assets existentes.

CSS ou JS retorna 404

Use:

{{asset "built/screen.css"}}
{{asset "built/main.js"}}

No Ghost 6, arquivos sem extensão na raiz do tema não são servidos. Coloque assets dentro de assets/.

{{#get}} não retorna conteúdo

Confira:

  • conteúdo publicado;
  • slug;
  • tag interna e seu slug hash-*;
  • filtro;
  • limite;
  • contexto;
  • logs do Ghost.

Astro retorna zero posts

Confira:

  • GHOST_URL sem barra final;
  • Content API Key;
  • API v6.0;
  • DNS e HTTPS;
  • filtro;
  • conteúdo publicado;
  • acesso do ambiente de build ao CMS.

Teste:

curl -H "Accept-Version: v6.0" \
  "https://cms.seudominio.com/ghost/api/content/posts/?key=SUA_CHAVE&limit=1"

Build tem apenas 100 posts

Ghost 6 removeu limit=all. Implemente loop usando meta.pagination.pages.

Publicou no Ghost, mas não apareceu no Astro

O site estático precisa de novo build. Confira:

  • entrega do webhook;
  • resposta 2xx;
  • segredo do deploy hook;
  • log do provedor;
  • cache/CDN;
  • branch correta;
  • filtro do post;
  • horário de publicação.

CORS

Se a chamada ocorre no navegador, use o domínio correto da API e configure CORS. Melhor ainda: em site estático, faça a consulta durante o build, evitando dependência client-side.

Imagens quebram após separar o CMS

O HTML contém URLs absolutas do Ghost. O domínio cms. precisa servir /content/images/ publicamente, ou você precisa copiar/proxyar imagens de forma planejada.

SEO duplicado no tema

Provavelmente você escreveu meta tags manualmente e manteve {{ghost_head}}. Inspecione o HTML e remova duplicatas.

Animação pisca

Não esconda conteúdo no CSS inicial. Deixe-o visível e faça o GSAP partir do estado visível ou aplique uma classe apenas depois que JavaScript carregar. Usuários sem JS não devem receber página vazia.

Roteiro de implementação em quatro semanas

Semana 1: estrutura

  1. inventário de páginas;
  2. conteúdo real;
  3. wireframes;
  4. tokens visuais;
  5. Ghost local;
  6. modelo de tags e páginas;
  7. tema mínimo;
  8. Git.

Semana 2: componentes

  1. header e footer;
  2. homepage;
  3. cards;
  4. blog;
  5. pages;
  6. conteúdo do editor;
  7. responsividade;
  8. acessibilidade básica.

Semana 3: acabamento

  1. imagens;
  2. formulários;
  3. newsletter;
  4. animações;
  5. SEO;
  6. 404;
  7. redirects;
  8. testes em aparelhos.

Semana 4: produção

  1. staging;
  2. GScan;
  3. performance;
  4. segurança;
  5. backup;
  6. deploy;
  7. smoke test;
  8. documentação editorial.

Se escolher headless, acrescente:

  • integração API;
  • rotas Astro;
  • RSS e sitemap;
  • webhook;
  • preview;
  • cache;
  • segundo deploy;
  • teste de falha do CMS.

Recomendação final para o projeto do anexo

Eu usaria:

Ghost 6
Tema Handlebars personalizado
CSS próprio com tokens
GSAP apenas no hero e reveals
Portal para newsletter
n8n para contato
Nginx/Caddy e HTTPS
Git + GScan + staging

Estrutura:

Ghost
├── página inicio
├── página sobre
├── página contato
├── posts #servico
├── posts #projeto
├── posts #equipe
├── posts #depoimento
├── posts blog
├── members
├── newsletter
└── tema atelier-norte

Eu mudaria para Astro somente se o projeto ganhar:

  • catálogo externo estruturado;
  • mapa e filtros complexos;
  • simulador;
  • dados de CRM no frontend;
  • múltiplos sites consumindo o mesmo Ghost;
  • aplicação autenticada;
  • equipe preparada para manter duas plataformas.

Não escolha headless para “poder usar componentes”. Partials, templates, custom settings, helpers e routing do Ghost já cobrem um site institucional sofisticado. Escolha headless quando a arquitetura do produto exigir, não como símbolo de modernidade.

Recomendações práticas

  1. Construa primeiro um protótipo completo como tema Ghost.
  2. Use conteúdo real antes de definir animações.
  3. Modele coleções com tags internas sem transformar tags em banco relacional.
  4. Mantenha hero e identidade no tema; mantenha textos longos no Admin.
  5. Não coloque GSAP, Lenis, Swiper e Three.js todos por padrão.
  6. Preserve ghost_head, ghost_foot, Portal, RSS e SEO nativos.
  7. Use custom settings para controles pequenos.
  8. Faça o formulário passar por validação server-side.
  9. Versione tema e routes.
  10. Mantenha staging e rollback.
  11. Se migrar para Astro, faça uma matriz de paridade antes: SEO, preview, members, newsletter, busca, RSS, sitemap, redirects e 404.
  12. Só desligue a apresentação nativa do Ghost depois que o frontend headless reproduzir tudo que realmente importa.

Conclusão

Ghost tradicional e Ghost headless conseguem produzir exatamente o mesmo visual. A diferença não é estética, mas operacional.

Um tema Ghost personalizado oferece liberdade suficiente para uma homepage empresarial sofisticada, conteúdo editorial, cases, animações e integrações sem abandonar as partes maduras do Ghost. É a melhor solução para o cenário descrito porque mantém publicação instantânea, preview, membros, newsletter, SEO, RSS e deploy num único sistema.

Ghost com Astro é excelente quando o site deixa de ser principalmente editorial e passa a compor catálogos, mapas, APIs, personalização e experiências de aplicação. Nesse cenário, a complexidade adicional tem função. Fora dele, headless tende a reconstruir recursos que o Ghost já entrega.

O caminho sensato é começar pelo tema personalizado, construir conteúdo e experiência com qualidade, medir as limitações reais e migrar para headless apenas quando uma delas se tornar concreta.

Fontes consultadas


Nota sobre atualidade

Pesquisa concluída em 29 de julho de 2026. Ghost, Astro, Tailwind, GSAP, provedores de deploy e integrações de membros mudam com o tempo. Antes de iniciar a produção ou atualizar uma instalação, confirme versões suportadas, breaking changes, requisitos de Node.js, sintaxe da API e documentação oficial atual.

Did this resonate?

Related documents