Astro calendar_today 23 апр. 2026 г. schedule 2 мин

Ghost + Astro: Headless CMS для блога с подписками

Как использовать Ghost CMS как Headless CMS с Astro.js: Content API, webhook-автоматизация, RSS, подписки и деплой. Полный гайд для медиа и блогеров.

person
Журналист
Автор
Ghost + Astro headless CMS — полный гайд

Ghost — лучшая CMS для серьёзных издателей: встроенные подписки, email-рассылки, монетизация платного контента. Astro — лучший фронтенд для читателей: PageSpeed 100, CDN, нулевой клиентский JS. Их связка — это профессиональный уровень издания контента в 2026 году.

Архитектура Ghost + Astro

code
┌─────────────────┐       ┌──────────────────────┐
│   Ghost Admin   │       │   Cloudflare Pages   │
│                 │       │                      │
│ Авторы пишут    │──────▶│  Astro SSG (HTML)    │
│ статьи в Ghost  │ API   │  PageSpeed: 100      │
│                 │       │  CDN: глобально      │
├─────────────────┤       └──────────────────────┘
│   Ghost Backend │
│                 │       ┌──────────────────────┐
│ Node.js + MySQL │──────▶│  Webhook → Rebuild   │
│ Подписки/Email  │       │  Публикация = 30 сек │
│ Newsletter      │       └──────────────────────┘
└─────────────────┘

Ghost хранит контент, управляет подписчиками, отправляет рассылки.
Astro показывает этот контент читателям с максимальной скоростью.

Установка Ghost

Ghost(Pro) — официальный облачный хостинг:

  • Starter: $9/мес (1 500 подписчиков)
  • Creator: $25/мес (неограниченно)

Self-hosted на VPS:

code
# Требуется Node.js 20+, MySQL 8, Nginx
npm install ghost-cli -g
mkdir /var/www/ghost && cd /var/www/ghost
ghost install

Content API: получение данных из Ghost

code
# В Ghost Admin → Settings → Integrations → Custom Integration
# Скопируйте Content API Key
code
// src/lib/ghost.ts
const GHOST_URL = import.meta.env.GHOST_URL; // https://your-blog.ghost.io
const GHOST_KEY = import.meta.env.GHOST_CONTENT_KEY; // API key из Admin

export interface GhostPost {
  id: string;
  slug: string;
  title: string;
  html: string;
  excerpt: string;
  feature_image: string | null;
  published_at: string;
  tags: { name: string; slug: string }[];
  primary_author: { name: string; profile_image: string | null };
  reading_time: number;
  meta_title: string | null;
  meta_description: string | null;
  og_image: string | null;
}

export async function getAllPosts(): Promise<GhostPost[]> {
  const res = await fetch(
    `${GHOST_URL}/ghost/api/content/posts/?key=${GHOST_KEY}&limit=all&include=tags,authors&order=published_at desc`,
  );
  if (!res.ok) throw new Error(`Ghost API error: ${res.status}`);
  const { posts } = await res.json();
  return posts;
}

export async function getPostBySlug(slug: string): Promise<GhostPost | null> {
  const res = await fetch(
    `${GHOST_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_KEY}&include=tags,authors`,
  );
  if (!res.ok) return null;
  const { posts } = await res.json();
  return posts[0] ?? null;
}

export async function getAllPages() {
  const res = await fetch(
    `${GHOST_URL}/ghost/api/content/pages/?key=${GHOST_KEY}&limit=all`,
  );
  const { pages } = await res.json();
  return pages;
}

export async function getSettings() {
  const res = await fetch(`${GHOST_URL}/ghost/api/content/settings/?key=${GHOST_KEY}`);
  const { settings } = await res.json();
  return settings;
}

Список статей (главная и блог)

code
---
// src/pages/blog/index.astro
import { getAllPosts } from '../../lib/ghost';
import BaseLayout from '../../layouts/BaseLayout.astro';

const posts = await getAllPosts();
---

<BaseLayout title="Блог" description="Все статьи">
  <h1>Все публикации</h1>
  <div class="posts-grid">
    {
      posts.map((post) => (
        <article class="post-card">
          {post.feature_image && (
            <img
              src={post.feature_image}
              alt={post.title}
              width="800"
              height="450"
              loading="lazy"
            />
          )}
          <div class="post-card__content">
            <h2>
              <a href={`/blog/${post.slug}/`}>{post.title}</a>
            </h2>
            <p>{post.excerpt}</p>
            <div class="post-card__meta">
              <time datetime={post.published_at}>
                {new Date(post.published_at).toLocaleDateString('ru-RU', {
                  year: 'numeric',
                  month: 'long',
                  day: 'numeric',
                })}
              </time>
              <span>{post.reading_time} мин</span>
            </div>
            <ul class="tags">
              {post.tags.map((tag) => (
                <li>
                  <a href={`/tag/${tag.slug}/`}>{tag.name}</a>
                </li>
              ))}
            </ul>
          </div>
        </article>
      ))
    }
  </div>
</BaseLayout>

Страница статьи

code
---
// src/pages/blog/[slug].astro
import { getAllPosts, getPostBySlug } from '../../lib/ghost';
import BaseLayout from '../../layouts/BaseLayout.astro';

export async function getStaticPaths() {
  const posts = await getAllPosts();
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;

// SEO
const seoTitle = post.meta_title ?? post.title;
const seoDesc = post.meta_description ?? post.excerpt;
const seoImage = post.og_image ?? post.feature_image;

// JSON-LD
const articleSchema = {
  '@context': 'https://schema.org',
  '@type': 'Article',
  headline: post.title,
  description: post.excerpt,
  datePublished: post.published_at,
  author: {
    '@type': 'Person',
    name: post.primary_author.name,
  },
  image: post.feature_image ?? undefined,
};
---

<BaseLayout title={seoTitle} description={seoDesc} image={seoImage}>
  <script type="application/ld+json" set:html={JSON.stringify(articleSchema)} />

  <article>
    {
      post.feature_image && (
        <img
          src={post.feature_image}
          alt={post.title}
          width="1200"
          height="630"
          loading="eager"
          fetchpriority="high"
        />
      )
    }

    <header>
      <h1>{post.title}</h1>
      <div class="post-meta">
        <span>
          <img
            src={post.primary_author.profile_image ?? ''}
            alt=""
            width="32"
            height="32"
          />
          {post.primary_author.name}
        </span>
        <time datetime={post.published_at}>
          {
            new Date(post.published_at).toLocaleDateString('ru-RU', {
              year: 'numeric',
              month: 'long',
              day: 'numeric',
            })
          }
        </time>
        <span>{post.reading_time} мин чтения</span>
      </div>
    </header>

    <!-- Ghost возвращает готовый HTML -->
    <div class="ghost-content" set:html={post.html} />

    <footer>
      <ul>
        {
          post.tags.map((tag) => (
            <li>
              <a href={`/tag/${tag.slug}/`}>{tag.name}</a>
            </li>
          ))
        }
      </ul>
    </footer>
  </article>
</BaseLayout>

Webhook: Автопересборка при публикации

Когда автор публикует статью в Ghost → Astro должен пересобраться.

В Ghost Admin → Settings → Integrations → Custom Integration → Add webhook:

  • Event: Post published
  • Target URL: https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/YOUR_HOOK_ID

Или через Cloudflare Pages → Deployments → Deploy hooks → Create hook. Скопируйте URL и вставьте в Ghost.

code
Ghost публикует статью
  → Ghost шлёт POST на Cloudflare deploy hook
    → Cloudflare запускает сборку Astro (~30-60 сек)
      → Новая статья доступна на CDN

Стилизация Ghost HTML

Ghost возвращает HTML с kg-* классами (Koenig Editor). Добавьте CSS:

code
/* src/styles/ghost-content.css */

/* Карточки галерей */
.ghost-content .kg-gallery-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 8px;
}

/* Карточки закладок */
.ghost-content .kg-bookmark-card {
  border: 1px solid var(--color-border);
  border-radius: 8px;
  overflow: hidden;
}

/* Видео */
.ghost-content .kg-video-card video {
  width: 100%;
  height: auto;
}

/* Код */
.ghost-content pre {
  background: var(--color-surface-container);
  border-radius: 8px;
  padding: 16px;
  overflow-x: auto;
}

/* Цитаты */
.ghost-content blockquote {
  border-left: 4px solid var(--color-primary);
  padding-left: 16px;
  font-style: italic;
}

Форма подписки Ghost Members

Ghost поддерживает нативные подписки. Форма через Ghost Portal:

code
---
// Загрузить Ghost Portal скрипт
---

<!-- В BaseLayout.astro -->
<script defer src="https://your-blog.ghost.io/public/member-attribution.min.js"></script>

<!-- Кнопка подписки — открывает Ghost Portal -->
<a href="#/portal/signup" class="btn btn--primary"> Подписаться на рассылку </a>

<!-- Или кастомная форма -->
<form
  data-members-form="subscribe"
  action="https://your-blog.ghost.io/members/api/send-magic-link/"
  method="post"
>
  <input name="email" type="email" placeholder="Ваш email" required />
  <button type="submit">Подписаться</button>
  <p data-members-error></p>
</form>

Кэширование и производительность

Ghost API может быть медленным при большом количестве постов. Кэшируйте данные при сборке:

code
// src/lib/ghost.ts — с локальным кэшем для dev-режима
let postsCache: GhostPost[] | null = null;

export async function getAllPosts(): Promise<GhostPost[]> {
  if (import.meta.env.DEV && postsCache) {
    return postsCache; // Кэш в dev-режиме
  }

  const res = await fetch(`${GHOST_URL}/ghost/api/content/posts/?...`);
  const { posts } = await res.json();
  postsCache = posts;
  return posts;
}

Итог

Ghost + Astro — одна из самых сильных связок для медиапроектов. Редакторы получают удобный интерфейс Ghost с подписками и рассылками. Читатели получают молниеносный статический сайт на CDN. Webhook-автоматизация обеспечивает публикацию за 30-60 секунд. Если вы серьёзный издатель — это ваш стек.

Подробнее о Ghost как CMS — в статье Astro vs Ghost.

Портрет автора Дмитрий Соколов

Senior Frontend Engineer / Tech Writer

Senior Frontend Engineer с 9-летним опытом. Специализируется на Astro.js и JAMstack.

Комментарии

Загрузка комментариев...

Оставить комментарий

Комментарии проходят модерацию перед публикацией. Правила

Рекомендуем к прочтению