How to build a Ghost alternative
How to build a Ghost alternative with markdown posts, a public permalink, and an RSS feed that hides drafts. Follow the prompts and skip paid members.
How to build a Ghost alternative is a post table, a public site, and an RSS feed. Ghost Pro is that product. The catalog calls it “A blog that emails itself to your readers.”
You pay $9–$35 a month. That is $108–$420 a year. The verdict is “Own the printing press”. The first estimate is 12 hours: markdown posts, a public site, an RSS feed.
The Ghost teardown already separates the press from paid memberships.
The audience is one writer publishing under their own domain. If that writer already sells tiers, themes, and a shared newsroom, keep paying. The same v1 also replaces Substack, Medium, and WordPress.com when what you wanted was a site you host. One landing page is a smaller job. That is the Carrd guide, at $2–$4 a month, about 2 hours.
How to build a Ghost alternative as posts and a feed
The Ghost Pro page splits the work into three rows.
The first row is the one you want. A markdown body. A public home and a permalink. /rss.xml built from published posts. That is the 12-hour v1.
The second row is 4 days, 32 hours. Newsletter sends, double opt-in, tags, and scheduling. Build that after people open the archive.
The third row is 1 month and up, 160 hours. Paid memberships, a theme engine, multi-author roles. The non-goal already names paid memberships, Stripe billing, multi-author roles, and a theme marketplace.
If you tell an agent to “clone Ghost”, it aims at the third row. Pin every prompt to posts, the public pages, and the feed.
The catalog names the project Pressroom. The sample post is “Vibe code your own Ghost”, slug vibe-code-your-own-ghost. Flavio is the only writer. A second draft, “Desk notes”, stays hidden until you publish it.
The parts and where a publish goes
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Ghost Pro is Astro, D1, and Workers. This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why the membership row waits.
Pressroom stores rows. Publish is a form post that sets the status and publishedAt in one write. That is what D1 is for. Object storage waits, because this row has no image upload. A cron trigger waits, because publish is a button on the editor. An email provider waits, because a send is the 4-day row. TanStack Start keeps the same screens, with server functions and a SQL database with migrations. The hosting post is the D1 binding when you leave localhost.
The writer and the public hit one app. Admin forms require a session. The home, the permalink, the feed, and the sitemap are public. All four read the same rows.
The data one post carries
The card lists Post, Tag, Subscriber, and EmailSend. This v1 needs Post, plus a site record for the name and the public URL. Tag archives, the subscriber list, and the send log wait until someone is reading.
A site has a name, a short description, and a base URL. The name is Pressroom. The description is “Notes from the press.” The base URL is the domain you attach, such as https://press.flaviocopes.com. One writer. The password lives in the environment, outside the repo.
A post carries a stable id, a title, a slug, a markdown body, a status, and publishedAt. The status is draft or published. publishedAt is empty on a draft. Publish fills it with the current time. The public URL is the base URL plus the slug. Admin pages use the id. A slug edit in draft keeps the editor tab.
The slug is unique among drafts and published posts. You can change it while the status is draft. Publish locks it. A later title edit keeps the slug. Moving the post back to draft removes it from the home, the sitemap, and the feed, and the slug stays reserved. A new draft cannot take a URL a post has already published.
The row stores markdown. The server renders HTML on the request and sanitizes it. The preview and the public page share that renderer. A pasted script stays text.
Here is the shape of the sample after you publish it.
const post = {
slug: 'vibe-code-your-own-ghost',
title: 'Vibe code your own Ghost',
status: 'published',
publishedAt: '2026-10-25T07:00:00.000Z',
}
The body stays markdown in the row. A draft uses the same fields, with status draft and no publishedAt. The catalog rule is the filter: public lists read published only.
A guessed admin URL with no cookie returns the login wall. A guessed permalink for a draft returns a not-found page. The HTML omits the draft title.
The screens you ship
Three surfaces, plus the feed. Keep them boxes and labels. If the agent starts matching Ghost’s admin chrome, send it back to these boxes.
The post list is the writer’s home, at /admin/posts. Drafts and Published are separate groups. Each row shows the title, the status, and the date once a post has been published. New post asks for a title, creates a draft, and opens the editor. An empty list says the next step is that first title.
The editor is one post. It shows the title, the slug, the markdown, and a preview. Save keeps the draft and leaves the slug editable. Publish sets the status, stamps publishedAt, locks the slug, and offers the public URL. Move to draft is on the page after publish. The preview uses the same sanitized HTML as the public page.
The public post is the permalink. It shows the title, the date from publishedAt, and the rendered body. The home uses the same header, lists published titles newest first, and links each title here. Both pages link to /rss.xml. A draft URL shows a not-found page. When nothing is published, the home says the press is empty, and the header still links to the feed.
The feed is XML at /rss.xml. The sitemap lists those same permalinks. A reader that polls feeds is a different product. The steps for that side are the RSS reader guide.
The path from a draft to the feed
One post moves through states you can point at. Draft on the admin list. Published on the home. Present in the feed. Or back to draft, gone from both public surfaces, with the slug still reserved.
- Flavio signs in and creates “Vibe code your own Ghost”. The status is draft. The slug starts as
vibe-code-your-own-ghost. He can still edit that slug. - He writes a short markdown body and reads the preview. The public home still says the press is empty.
/rss.xmlhas no items. - He publishes.
publishedAtis the time of that click. The slug locks. The home lists the title. The permalink renders the body. - He opens
/rss.xml. The item has the title, an absolute link, and the date. The guid is the permalink. - He creates “Desk notes” and leaves it as a draft. The home still shows one post. The feed still shows one item. The draft permalink is a not-found page.
- He changes the published title. The permalink stays. He moves the post back to draft. The home is empty. The feed is empty. The slug
vibe-code-your-own-ghostis still reserved.
Prompt 1. Sign in and an empty press
Build Pressroom, a blog for
one writer.
Use Node.js and Astro.
Server-rendered pages.
Ordinary HTML forms.
Cloudflare D1 for the rows.
If you use TanStack Start
instead, use server functions
and a SQL database with
migrations. Same screens.
Same rules. No job runner.
Seed a site named Pressroom.
The description is "Notes
from the press." The public
base URL comes from env.
Flavio is the only writer.
The password lives in env,
not in the repo. Sign-in
sets an httpOnly session
cookie.
Admin pages live under
/admin. Every admin page
and every mutation checks
the session on the server.
A signed-out visitor gets
a login wall. Public pages
need no session.
One site. One writer. No
billing. No member accounts.
No theme upload.
Done when Flavio can sign
in, /admin/posts is an
empty list, the public home
loads, and a private window
cannot open the admin.
What to check after login exists
Sign in as Flavio. /admin/posts is empty and offers a new post.
Open /admin/posts in a private window. You get the login wall. The public home loads without a cookie and says the press is empty. /rss.xml is valid XML with zero items.
Prompt 2. Draft, lock the slug, publish
The slug rule is already on the card. Slugs are unique, and they stay put once a post has been public.
Add posts to Pressroom.
New post asks for a title
and creates a draft. The
slug is the title in
lowercase words joined by
hyphens. The writer can
edit the slug while the
status is draft. Empty
slugs are refused. A slug
another post already has
is refused, and the form
names the post that owns it.
The editor shows title,
slug, markdown, and a
preview. Save keeps the
draft. Publish sets status
to published, sets
publishedAt to the current
time, and locks the slug.
After publish, the title
can change and the slug
cannot. Move to draft sets
status back to draft,
clears the post from public
pages, and keeps the slug
reserved.
Admin URLs use a stable id,
not the slug. The public
URL is the slug. Copy link
copies the public URL.
No schedule field. No tags.
No image upload.
Done when a published slug
survives a title edit, a
second draft cannot take
it, and unpublishing keeps
the slug reserved.
What to check after the first publish
Create “Vibe code your own Ghost”. The slug is vibe-code-your-own-ghost. While it is a draft, change the slug to press-notes, save, then change it back.
Publish. Change the title to “Vibe code your own press”. The public URL is still /vibe-code-your-own-ghost/. The slug field does not save a new value.
Create “Desk notes” and try to save the slug vibe-code-your-own-ghost. The form refuses. Move the first post to draft. The public URL is a not-found page, and Desk notes still cannot take that slug. Publish the first post again. The URL is the same.
Prompt 3. Home, permalink, and safe HTML
Build the public site for
Pressroom.
The home lists published
posts, newest publishedAt
first. Each row is the
title, the date, and a link
to the permalink. The
permalink shows the title,
the date, and the body
rendered from markdown.
Drafts are absent from the
home. A draft permalink
returns a not-found page
and does not include the
draft title. When nothing
is published, the home says
the press is empty and
still links to /rss.xml.
Render markdown on the
server. Sanitize the HTML.
The editor preview uses
that same renderer. A
pasted script does not run
on the preview or the
public page. Strip unsafe
link protocols.
No signup form. No account
menu on the public pages.
No theme picker.
Done when the home, the
permalink, and the preview
agree, and a draft URL
leaks nothing.
What to check on the public page
Publish the sample. The home shows the title and the date. The permalink renders the body. Open the draft “Desk notes” by its slug. You get not found, and view source does not contain “Desk notes”.
Paste a script tag into the markdown. The preview shows it as text or drops it. Reload the public page. The script does not run. The home order follows publishedAt, newest first.
Prompt 4. A feed that lists published posts
The RSS mini-prompt on the Ghost Pro page also covers tag feeds and scheduled posts. This prompt is the 12-hour slice. One feed. Published rows only.
Add /rss.xml and a sitemap
to Pressroom.
The feed lists published
posts only, newest first.
Drafts never appear in the
feed, the sitemap, or the
home. Each item has the
title, the absolute public
URL, the date in RFC 822
form, and the rendered HTML
escaped so the document
stays valid. The guid is
the permalink and stays
stable if the title changes.
Escape titles. A title with
an ampersand must not break
the XML. Links are absolute,
using the site base URL.
Send an ETag for the feed.
If the client sends that
tag back and no post was
published or unpublished,
respond 304 with an empty
body. Publishing or moving
a post to draft changes
the ETag, so the next fetch
is 200 and matches the
public home.
One feed. No per-tag feed.
No email. The sitemap lists
the same permalinks as the
feed.
Done when a strict feed
check passes, Desk notes
is absent, and unpublishing
removes the item on the
next fetch.
What to check in the feed
Publish the sample. The feed has one item. The link starts with the base URL. The guid matches the permalink. Add a draft titled “Drafts & feeds”. The home hides it. The feed still parses, and the draft title is absent.
Fetch the feed and note the ETag. Fetch again with that tag. You get 304. Move the published post to draft and fetch with the old tag. You get 200, and the item is gone. Publish it again. The guid is unchanged, so a reader does not treat the old essay as a new one.
Failures the agent ships anyway
A draft in a public list
Drafts stay out of the home, the feed, and the sitemap. Search the XML for “Desk notes”. If the title is there, the filter is on the wrong status. The not-found page has the same bug when the HTML includes the draft title.
A slug that moves
Change the title after publish and open the old URL. If it 404s and a new slug works, the lock is missing. Readers who saved the first URL land on nothing. After you move the post back to draft, a new draft that saves vibe-code-your-own-ghost means the unique rule ignored that row.
A script that runs
Paste a script into the body. If a browser dialog appears on the preview or the permalink, the renderer treated markdown HTML as trusted. The preview and the public page share one sanitizer. View source is the check. An httpOnly cookie still leaves that script running in the page.
A feed a reader cannot parse
Title the draft you never publish “Drafts & feeds”, and also try that title on a published post. If the XML parser stops at the ampersand, the title was inserted raw. Relative links fail the same way, because a feed reader has no base URL. The guid has to stay on the permalink when the title changes. A new guid delivers the whole archive again as unread.
Memberships on day one
The teardown walks Ghost’s Content API, Admin API, editor, theme layer, and Mailgun. The 12-hour row is a table and a full page render. Stop when the plan mentions Stripe, a theme upload, or a second writer. The non-goal already names that work.
A list with no public archive is Mailchimp, $20–$350 a month, about 12 hours. Markdown that also arrives in inboxes is Kit, $29–$79 a month, about 10 hours. Build those after this feed has real posts.
A clock that publishes for you
Scheduled posts are the 4-day row. The mini-prompt on the product page says a post can become public when its time has passed, and email stays a separate button. This v1 has no future time. publishedAt is the click. You notice the extra job when the Worker has a cron trigger before the feed works. Publish stays a button on the editor.
Deploy the press
Pressroom wants Astro pages and a D1 database, on a Worker so the forms have somewhere to run. Put it on a subdomain you already own, such as press.flaviocopes.com. The deploy is the pages, the D1 binding, the base URL, the password, and the domain.
The hosting post is that path: the adapter, then the D1 binding, then the custom domain.
Create the database. Run the migration. Set the base URL and the password. Sign in. Publish “Vibe code your own Ghost”. Open the permalink on a phone. Subscribe to /rss.xml in a reader. Move the post to draft and confirm the reader drops it on the next fetch. Take a D1 backup before you cancel Ghost Pro. The posts are the archive you will miss.
Use it for a few real essays. Tags, a schedule, and a confirm-and-send loop are the 4-day row on the Ghost Pro page. If the publication already lives in paid tiers and a theme marketplace, keep the subscription.
What to skip on purpose
Leave out paid memberships, Stripe, the theme engine, multi-author roles, tags, scheduling, and the newsletter.
The double opt-in mini-prompt and the resumable send mini-prompt are on the Ghost Pro page. Both wait until the archive has readers. When you get there, copy the guardrail. Email only confirmed subscribers. Honor an unsubscribe in the same request. Make each post’s send happen once.
More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own Ghost Pro. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate only. Markdown posts, a public site, an RSS feed. Twelve hours.
Publish one real essay and open the feed before you add a signup form.