To integrate Replit with Ghost, generate a Content API key for public content and an Admin API key for write access from your Ghost Admin settings, store them in Replit Secrets (lock icon π), and call the Ghost Content or Admin API from Python or Node.js. Ghost is a modern headless blog CMS with clean REST and JSON Content APIs suited for building custom frontends on Replit.
Why Connect Replit to Ghost?
Ghost is one of the cleanest headless CMS options available β it delivers well-structured JSON for posts, pages, tags, authors, and newsletters without the configuration overhead of WordPress. Many technical founders use Ghost for their blog or documentation, then want to build a custom frontend, embed content in their app, or automate publishing workflows from code. Replit is an excellent platform for both the backend API layer and for running automation scripts that interact with Ghost.
The Ghost Content API is designed for public consumption β you use a key as a query parameter to fetch published content, and it is safe to call from browser clients. The Ghost Admin API is the power user interface: it can create drafts, publish posts, manage members, upload images, and send newsletters. The Admin API requires a signed JWT token generated from your Admin API key, making it server-only by design β a perfect match for Replit's backend code.
Common Replit + Ghost integration patterns include building a custom blog frontend that fetches Ghost posts and renders them with a custom design, automating cross-posting from Ghost to social media triggered by new post webhooks, batch-importing posts from another platform during a Ghost migration, and building a Members API dashboard showing subscriber analytics.
Integration method
You connect Replit to Ghost by creating API keys in your Ghost Admin panel and storing them in Replit Secrets. The Content API (read-only, safe for public clients) uses a key as a query parameter. The Admin API (full read/write access) requires a JWT token generated from your Admin API key and should only be called from your server-side Replit backend. Both APIs return clean JSON with predictable schemas.
Prerequisites
- A Replit account with a Python or Node.js project created
- A Ghost installation (Ghost Pro hosted account at ghost.org, or self-hosted Ghost instance)
- Access to Ghost Admin panel to generate API keys
- Basic familiarity with REST APIs and JSON
- Node.js 18+ or Python 3.10+ (both available on Replit by default)
Step-by-step guide
Generate Ghost API Keys
Generate Ghost API Keys
Log in to your Ghost Admin panel (typically at yourdomain.com/ghost) and navigate to Settings in the left sidebar. Scroll down and click on 'Integrations'. Click 'Add custom integration' and give it a name like 'Replit Integration'. After creating the integration, you will see two API keys: The Content API Key is a long string used as a query parameter for public read-only requests. It is safe to use in client-side code and does not require authentication headers. The Admin API Key is in the format ID:SECRET (with a colon separating two components). This key is used to generate JWT tokens for Admin API requests. The Admin API key grants full write access to your Ghost installation β treat it like a database password. Also note your Ghost API URL β this is your Ghost site URL (e.g., https://yourblog.ghost.io or https://yourdomain.com). All API endpoints are constructed as GHOST_URL/ghost/api/content/... and GHOST_URL/ghost/api/admin/... Copy both keys and your Ghost URL before leaving the Integrations page.
Pro tip: Ghost Cloud (ghost.org) and self-hosted Ghost instances both use the same API. The only difference is your base URL β ghost.io domains for hosted accounts and your custom domain for self-hosted.
Expected result: You have a Content API key, an Admin API key (in ID:SECRET format), and your Ghost URL ready to add to Replit Secrets.
Store Ghost Credentials in Replit Secrets
Store Ghost Credentials in Replit Secrets
Open your Replit project and click the lock icon π in the left sidebar. Add the following secrets: - Key: GHOST_URL β Value: your Ghost site URL (e.g., https://yourblog.ghost.io), no trailing slash - Key: GHOST_CONTENT_API_KEY β Value: your Content API key - Key: GHOST_ADMIN_API_KEY β Value: your Admin API key in full ID:SECRET format In Python, access these with os.environ['GHOST_URL'] etc. In Node.js, use process.env.GHOST_URL. The Content API key is relatively low risk (read-only, public content), but the Admin API key provides full write access including creating posts, managing members, and sending newsletters β store it in Replit Secrets and never expose it to client-side code or commit it to Git. Replit's Secret Scanner monitors for accidentally committed API keys.
Pro tip: Ghost API keys are specific to the Ghost installation, not to individual user accounts. If you delete and recreate the custom integration, you will get new keys and need to update Replit Secrets.
Expected result: GHOST_URL, GHOST_CONTENT_API_KEY, and GHOST_ADMIN_API_KEY appear in Replit Secrets with masked values.
Fetch Content Using Python
Fetch Content Using Python
The Ghost Content API is the simplest API β include your content key as the key query parameter and you receive clean JSON. The API supports filtering, pagination, sorting, and field selection. Endpoints include /posts/, /pages/, /tags/, /authors/, and /tiers/. The Admin API requires a JSON Web Token (JWT) generated from your Admin API key. Split the key on ':' to get the key ID and secret, then sign a JWT payload with a 5-minute expiry. The Ghost Admin API documentation shows the exact JWT structure required. The code below implements a complete Ghost client for both the Content and Admin APIs, including JWT token generation for Admin calls.
1import os2import time3import hmac4import hashlib5import struct6import base647import json8import requests9from typing import Optional1011GHOST_URL = os.environ["GHOST_URL"].rstrip("/")12CONTENT_KEY = os.environ["GHOST_CONTENT_API_KEY"]13ADMIN_API_KEY = os.environ["GHOST_ADMIN_API_KEY"]1415# ---- Content API (public, read-only) ----1617def get_posts(limit: int = 10, include: str = "tags,authors", filter: str = "") -> list:18 """Fetch published posts from Ghost Content API."""19 params = {20 "key": CONTENT_KEY,21 "limit": limit,22 "include": include,23 "order": "published_at desc"24 }25 if filter:26 params["filter"] = filter27 response = requests.get(f"{GHOST_URL}/ghost/api/content/posts/", params=params)28 response.raise_for_status()29 return response.json().get("posts", [])3031def get_post_by_slug(slug: str) -> Optional[dict]:32 """Fetch a single post by its slug."""33 params = {"key": CONTENT_KEY, "include": "tags,authors"}34 response = requests.get(f"{GHOST_URL}/ghost/api/content/posts/slug/{slug}/", params=params)35 if response.status_code == 404:36 return None37 response.raise_for_status()38 posts = response.json().get("posts", [])39 return posts[0] if posts else None4041def get_tags() -> list:42 """Fetch all tags from Ghost."""43 params = {"key": CONTENT_KEY, "limit": "all"}44 response = requests.get(f"{GHOST_URL}/ghost/api/content/tags/", params=params)45 response.raise_for_status()46 return response.json().get("tags", [])4748# ---- Admin API (requires JWT) ----4950def _generate_admin_jwt() -> str:51 """Generate a Ghost Admin API JWT token (valid for 5 minutes)."""52 key_id, secret = ADMIN_API_KEY.split(":")53 header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "kid": key_id, "typ": "JWT"}).encode()).rstrip(b"=").decode()54 iat = int(time.time())55 payload = base64.urlsafe_b64encode(json.dumps({"iat": iat, "exp": iat + 300, "aud": "/admin/"}).encode()).rstrip(b"=").decode()56 signature_input = f"{header}.{payload}".encode()57 secret_bytes = bytes.fromhex(secret)58 sig = hmac.new(secret_bytes, signature_input, hashlib.sha256).digest()59 signature = base64.urlsafe_b64encode(sig).rstrip(b"=").decode()60 return f"{header}.{payload}.{signature}"6162def _admin_headers() -> dict:63 return {"Authorization": f"Ghost {_generate_admin_jwt()}", "Content-Type": "application/json"}6465def create_post(title: str, html: str, status: str = "draft", tags: list = None) -> dict:66 """Create a post via the Ghost Admin API."""67 body = {68 "posts": [{69 "title": title,70 "html": html,71 "status": status, # 'draft' or 'published'72 "tags": [{"name": t} for t in (tags or [])]73 }]74 }75 response = requests.post(76 f"{GHOST_URL}/ghost/api/admin/posts/",77 headers=_admin_headers(),78 json=body79 )80 response.raise_for_status()81 return response.json().get("posts", [{}])[0]8283def update_post(post_id: str, updated_at: str, **fields) -> dict:84 """Update a Ghost post. updated_at is required for conflict detection."""85 body = {"posts": [{"updated_at": updated_at, **fields}]}86 response = requests.put(87 f"{GHOST_URL}/ghost/api/admin/posts/{post_id}/",88 headers=_admin_headers(),89 json=body90 )91 response.raise_for_status()92 return response.json().get("posts", [{}])[0]9394if __name__ == "__main__":95 posts = get_posts(limit=5)96 print(f"Latest {len(posts)} posts:")97 for p in posts:98 print(f" {p['title']} ({p['slug']}) β {p['published_at']}")99100 tags = get_tags()101 print(f"\nTags: {[t['name'] for t in tags]}")Pro tip: When creating a post via the Admin API, you can use either html (standard HTML) or lexical (Ghost's native editor format) as the content format. Using html is simpler for programmatically generated content. Note that Ghost will convert your HTML to its internal format during import.
Expected result: Running the script prints the 5 most recent Ghost post titles with their slugs and publish dates, plus all available tags.
Build a Node.js Ghost Integration
Build a Node.js Ghost Integration
The official @tryghost/admin-api and @tryghost/content-api npm packages handle JWT generation and HTTP calls automatically, making Ghost API integration in Node.js very clean. Install them with npm install @tryghost/admin-api @tryghost/content-api express in the Replit Shell. The Ghost JavaScript SDKs abstract away the JWT token generation for Admin API calls and provide a clean Promise-based interface. The example below uses both SDKs in an Express server that exposes read endpoints via the Content API and write endpoints via the Admin API. Deploy with Autoscale for web applications built on Ghost content. If you are running an automation script that publishes content on a schedule, use a Scheduled deployment instead.
1const express = require('express');2const GhostContentAPI = require('@tryghost/content-api');3const GhostAdminAPI = require('@tryghost/admin-api');45const app = express();6app.use(express.json());78const GHOST_URL = process.env.GHOST_URL;9const CONTENT_KEY = process.env.GHOST_CONTENT_API_KEY;10const ADMIN_KEY = process.env.GHOST_ADMIN_API_KEY;1112const contentApi = new GhostContentAPI({13 url: GHOST_URL,14 key: CONTENT_KEY,15 version: 'v5.0'16});1718const adminApi = new GhostAdminAPI({19 url: GHOST_URL,20 key: ADMIN_KEY,21 version: 'v5.0'22});2324// GET /posts β list recent posts25app.get('/posts', async (req, res) => {26 try {27 const posts = await contentApi.posts.browse({28 limit: parseInt(req.query.limit) || 10,29 include: 'tags,authors',30 order: 'published_at desc'31 });32 res.json(posts);33 } catch (err) {34 res.status(500).json({ error: err.message });35 }36});3738// GET /posts/:slug β fetch a single post39app.get('/posts/:slug', async (req, res) => {40 try {41 const post = await contentApi.posts.read(42 { slug: req.params.slug },43 { include: 'tags,authors' }44 );45 res.json(post);46 } catch (err) {47 if (err.response?.status === 404) {48 return res.status(404).json({ error: 'Post not found' });49 }50 res.status(500).json({ error: err.message });51 }52});5354// GET /tags β list all tags55app.get('/tags', async (req, res) => {56 try {57 const tags = await contentApi.tags.browse({ limit: 'all' });58 res.json(tags);59 } catch (err) {60 res.status(500).json({ error: err.message });61 }62});6364// POST /admin/posts β create a new post65app.post('/admin/posts', async (req, res) => {66 const { title, html, status = 'draft', tags = [] } = req.body;67 if (!title || !html) {68 return res.status(400).json({ error: 'title and html are required' });69 }70 try {71 const post = await adminApi.posts.add(72 { title, html, status, tags: tags.map(name => ({ name })) },73 { source: 'html' }74 );75 res.status(201).json(post);76 } catch (err) {77 res.status(500).json({ error: err.message });78 }79});8081app.listen(3000, '0.0.0.0', () => {82 console.log('Ghost API server running on port 3000');83});Pro tip: The Ghost Content API SDK sorts results by published date descending by default. Use the order parameter to sort by other fields β for example, order: 'title asc' for alphabetical listing.
Expected result: The server starts on port 3000. A GET to /posts returns the 10 most recent published posts from your Ghost blog.
Common use cases
Custom Headless Blog Frontend
Build a Replit-hosted web application that fetches Ghost posts via the Content API and renders them with a fully custom design β one that may not be achievable with standard Ghost themes. Your Replit frontend calls its own backend, which proxies Ghost Content API requests and adds server-side rendering or caching.
Create a Flask web app with a / route that fetches the 10 most recent posts from the Ghost Content API (title, excerpt, slug, published_at, feature_image), renders them in an HTML template with Tailwind CSS styling, and includes pagination for older posts.
Copy this prompt to try it in Replit
Automated Post Publishing from External Sources
Build a Replit backend that monitors an external source β a newsletter inbox, a social feed, or a content pipeline β and automatically creates Ghost draft posts with the Admin API. Human editors then review and publish the drafts. This accelerates content workflows without fully automating publishing.
Write a Python script that reads a CSV file of post titles and markdown content, authenticates with the Ghost Admin API using a JWT generated from GHOST_ADMIN_API_KEY, and creates each row as a draft post in Ghost with status 'draft' and a specified tag.
Copy this prompt to try it in Replit
Content Aggregator with Ghost as Source
Use Ghost as the content backend for a larger application β for example, an app that aggregates blog posts from multiple sources and displays them in a unified feed. Your Replit backend periodically fetches new Ghost posts, processes them (extracting keywords, generating summaries), and stores them in your app database.
Build a Node.js script that fetches all Ghost posts published in the last 7 days using the Content API with the filter parameter, strips HTML from the mobiledoc content, and stores each post's title, slug, and plain-text excerpt in a PostgreSQL database.
Copy this prompt to try it in Replit
Troubleshooting
Content API returns 401 Unauthorized or empty posts array
Cause: The Content API key is incorrect, the Ghost URL has a trailing slash or wrong protocol, or no posts have been published in the Ghost instance.
Solution: Verify GHOST_URL in Replit Secrets has no trailing slash and uses https://. Copy the Content API key directly from Ghost Admin > Integrations. Make sure at least one post is published (not just saved as draft) in your Ghost admin. Test by opening GHOST_URL/ghost/api/content/posts/?key=YOUR_KEY in a browser.
1# Python: test Content API URL directly2test_url = f"{os.environ['GHOST_URL']}/ghost/api/content/posts/"3params = {'key': os.environ['GHOST_CONTENT_API_KEY'], 'limit': 1}4print(requests.get(test_url, params=params).json())Admin API JWT generation fails with 'Invalid token' error
Cause: The Admin API key is not in the correct ID:SECRET format, the secret is not valid hexadecimal, or the JWT expiry is too short or the server clock is skewed.
Solution: Verify the GHOST_ADMIN_API_KEY value is the full key string in ID:SECRET format as shown in Ghost Admin > Integrations. The secret portion must be a valid hex string. If using the Python JWT approach, ensure the system clock is reasonably accurate β Ghost validates JWT iat and exp claims with a small tolerance.
1# Python: validate Admin API key format2admin_key = os.environ['GHOST_ADMIN_API_KEY']3parts = admin_key.split(':')4print(f'Parts: {len(parts)}') # Should be 25print(f'ID length: {len(parts[0])}')6print(f'Secret is hex: {all(c in "0123456789abcdef" for c in parts[1].lower())}')Ghost Admin API returns 409 Conflict when updating a post
Cause: The Ghost Admin API uses optimistic concurrency β you must pass the post's current updated_at timestamp in your update request to prevent overwriting concurrent edits.
Solution: Fetch the post first to get its current updated_at value, then include that exact value in your update request body. If another edit occurred between your fetch and update, Ghost returns 409 β implement retry logic that re-fetches the post and tries the update again.
1# Python: fetch before update to get updated_at2current = requests.get(f"{GHOST_URL}/ghost/api/admin/posts/{post_id}/", headers=_admin_headers()).json()['posts'][0]3updated_at = current['updated_at']4# Now update with the correct updated_at5body = {'posts': [{'updated_at': updated_at, 'title': 'New Title'}]}Best practices
- Store GHOST_CONTENT_API_KEY and GHOST_ADMIN_API_KEY in Replit Secrets (lock icon π) β the Admin API key especially grants full write access and must never be exposed to clients.
- Use the Content API for any public-facing read operations β it is designed for this and is safe to cache aggressively, as its content only changes when posts are published.
- Always include updated_at in Ghost Admin API PUT requests to avoid 409 conflict errors from concurrent edits β fetch the post first to get the current value.
- Use Ghost's filter parameter for efficient server-side filtering rather than fetching all posts and filtering in your application code.
- When creating posts programmatically, use status: 'draft' until you have verified the content looks correct in the Ghost editor β accidentally publishing unreviewed content is difficult to undo at scale.
- Cache Ghost Content API responses in a key-value store with a TTL matching your publishing frequency β Ghost content does not change more often than you publish.
- Deploy with Autoscale for web frontends built on Ghost content; use Scheduled deployment for automation scripts that publish or import content on a regular cadence.
- Use the include parameter to fetch related data (tags, authors) in a single request rather than making separate API calls β Ghost's Content API supports include=tags,authors on most endpoints.
Alternatives
WordPress has a larger plugin ecosystem and more configuration options, while Ghost is leaner and better optimized for blogging and newsletter workflows with a cleaner API.
Joomla has built-in multilingual support and a broader content structure, while Ghost focuses on clean blog publishing with a simpler API surface.
Notion is a flexible team knowledge base with an API, while Ghost is purpose-built for public-facing blog publishing with built-in membership and newsletter features.
Frequently asked questions
How do I store Ghost API keys in Replit?
Click the lock icon π in the Replit sidebar to open Secrets. Add GHOST_URL with your Ghost site URL (no trailing slash), GHOST_CONTENT_API_KEY with your Content API key, and GHOST_ADMIN_API_KEY with your Admin API key in ID:SECRET format. Find these values in Ghost Admin under Settings > Integrations.
What is the difference between the Ghost Content API and Admin API?
The Content API is read-only and designed for public content delivery β use it to fetch published posts, pages, tags, and authors. It uses a simple API key as a query parameter. The Admin API provides full CRUD access including creating posts, managing members, and sending newsletters. It uses a signed JWT for authentication and should only be called from your server-side Replit code.
Does Ghost work with Replit for free?
Yes. Ghost has a free self-hosted option (hosted on your own server) and Ghost Pro starts at $9/month. The Ghost APIs work the same whether self-hosted or on Ghost Pro. Replit's free tier supports outbound API calls, so you can build and test Ghost integrations without a paid Replit plan. Always-on webhook receivers require Replit Core for deployed apps.
How do I create a Ghost post from Replit?
Use the Ghost Admin API with a POST request to /ghost/api/admin/posts/. You need to generate a JWT from your Admin API key (ID:SECRET format) and include it in the Authorization: Ghost TOKEN header. The post body should include title, html (or lexical), and optionally status (draft or published) and tags. The official @tryghost/admin-api npm package handles JWT generation automatically.
Can I use Ghost as a headless CMS with a Replit frontend?
Yes β Ghost is one of the best headless CMS options. Enable the Content API in Ghost Admin > Integrations, then fetch posts from your Replit backend using the Content API and render them with your own template. This approach gives you full control over design and performance while using Ghost for content management.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation