@indiepub/astro
The main Astro integration. This is the only package a theme needs to import directly — it wires up all the sub-packages, injects API routes, and provides virtual modules and middleware.
Installation
pnpm add @indiepub/astroUsage
import { indiepub } from '@indiepub/astro';
export default defineConfig({ integrations: [ indiepub({ title: 'My Site', author: { name: 'Jane Doe', url: 'https://example.com' }, }), ],});Configuration
interface IndiePubConfig { /** Inferred from Astro's site config if omitted */ siteUrl?: string; title?: string; description?: string; language?: string; /** * Bootstrap author used in feed metadata and OG images. * The admin profile page is the primary place to manage your author details at runtime — * changes there are stored in D1 and take effect immediately without a rebuild. * This config field is used as a build-time fallback (e.g. for feed author fields). */ author: { name: string; url?: string; email?: string; photo?: string; bio?: string; }; syndication?: { bluesky?: { handle: string; did?: string }; mastodon?: { instance: string; handle: string }; /** Publish to standard.site ATProto lexicons alongside Bluesky. Default: false. */ standardSite?: boolean; }; subscriptions?: { enabled: boolean; fromEmail?: string; tiers?: Array<{ id: string; name: string; price: number }>; stripe?: { /** 'direct' (self-hosted) or 'connect' (Comet). Default: 'direct' */ mode?: 'connect' | 'direct'; /** Connect service URL. Only used when mode is 'connect'. */ connectUrl?: string; }; }; /** Cloudflare D1 binding name (default: "DB") */ d1BindingName?: string; /** Cloudflare R2 binding name (default: "BUCKET") */ r2BindingName?: string; /** * Base URL for R2 media assets, e.g. "https://media.example.com". * Leave unset (or empty string) in local dev — the integration serves media * via the /media/[...path] route backed by the local R2 simulator. */ mediaUrl?: string;}Injected routes
Always active
| Route | Description |
|---|---|
GET /media/[...path] | Serve R2 media uploads (dev and prod fallback) |
GET /og/[slug] | Generated Open Graph PNG for an entry |
GET/POST /micropub | Micropub endpoint |
GET/POST /.well-known/webmention | Webmention endpoint |
GET /feed.xml | RSS 2.0 feed |
GET /feed.atom | Atom 1.0 feed |
GET /feed.json | JSON Feed |
GET /.well-known/nodeinfo | NodeInfo discovery redirect |
GET /nodeinfo/2.0 | NodeInfo 2.0 document |
GET /sitemap.xml | Dynamic XML sitemap (static pages + published entries + tag pages) |
GET /robots.txt | Robots.txt with Sitemap: directive and /admin/ disallow |
POST /admin/upload | Upload an image to R2 (admin-authenticated) |
POST /admin/compose | Quick-post from the inline note composer |
GET /admin/magic | Magic link handler for admin login |
Mastodon / ActivityPub (when syndication.mastodon is set)
| Route | Description |
|---|---|
GET /.well-known/webfinger | WebFinger identity discovery |
GET /actor | ActivityPub Actor document |
POST /actor/inbox | ActivityPub inbox |
GET /actor/outbox | ActivityPub outbox |
GET /actor/followers | Followers collection |
GET /actor/following | Following collection |
Bluesky / ATProto (when syndication.bluesky is set)
| Route | Description |
|---|---|
GET /.well-known/atproto-did | ATProto DID document |
GET /.well-known/site.standard.publication | standard.site publication record |
Subscriptions (when subscriptions.enabled is true)
| Route | Description |
|---|---|
POST /api/subscribe | Subscribe with an email address |
GET /api/unsubscribe | Unsubscribe via token |
GET /auth/login | Member magic-link login form |
GET /auth/magic | Magic link verification handler |
GET /auth/logout | Clear member session |
Stripe Connect (when subscriptions.stripe is set)
| Route | Description |
|---|---|
GET /stripe/checkout | Create a Stripe Checkout session and redirect. On ?success=1, upgrades the subscriber tier. |
POST /stripe/webhook | Stripe webhook handler — verifies signature, processes checkout.session.completed and customer.subscription.deleted events |
GET /stripe/portal | Redirect paid members to the Stripe Billing Portal for subscription management |
Stripe
IndiePub supports paid subscriptions via Stripe in two modes:
- Direct mode (default) — self-hosters use their own Stripe API keys. No platform dependency. You fully control your Stripe account.
- Connect mode — hosted platforms (e.g. Comet) use Stripe Connect OAuth to link site owners’ accounts. The platform handles the OAuth handshake; all runtime billing calls still use the site owner’s key.
In both modes, readers subscribe and pay the site owner directly.
Setup — Direct mode (self-hosted)
- Enable Stripe in your integration config:
indiepub({ subscriptions: { enabled: true, stripe: {}, // mode defaults to 'direct' },})-
In your Stripe Dashboard → API keys, copy your secret key. Set
STRIPE_SECRET_KEYas an environment variable on your Cloudflare Worker. -
In your Stripe Dashboard → Webhooks, add a new endpoint:
- URL:
https://yoursite.com/stripe/webhook - Events:
checkout.session.completed,customer.subscription.deleted - Copy the signing secret and set
STRIPE_WEBHOOK_SECRETas an environment variable.
- URL:
-
In your Stripe Dashboard → Products, create a product with a recurring price. Copy the Price ID.
-
Go to
/admin/stripeand paste the Price ID.
Setup — Connect mode (hosted platforms)
indiepub({ subscriptions: { enabled: true, stripe: { mode: 'connect' }, },})- Go to
/admin/stripeand click Connect with Stripe to link your account via OAuth. - Create a recurring Price in your Stripe Dashboard and paste the Price ID in the admin panel.
- Set
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETenvironment variables.
How paid content gating works
Entries have a visibility field with five levels: public, unlisted, members, paid, and private.
paidentries are visible only to subscribers withtier: 'paid'(i.e. active Stripe subscriptions)membersentries are visible to all logged-in subscribers (free and paid)- Feeds (
/feed.xml, etc.) excludepaidentries — they only includepublicentries
Theming paywalls
When a free member visits a paid entry, themes should show a paywall instead of the full content:
---if (entry.visibility === 'paid' && Astro.locals.member?.tier !== 'paid') { isPaidLocked = true;}---{isPaidLocked ? ( <div class="paywall"> <p>This post is for paid subscribers.</p> <a href="/stripe/checkout">Subscribe to unlock</a> </div>) : ( <EntryDetail entry={entry} />)}Sitemap & robots.txt
Both routes are injected automatically — no extra packages or theme config needed.
/sitemap.xml generates a dynamic sitemap on each request:
- Static theme pages discovered via Astro’s
astro:routes:resolvedhook (admin pages excluded) - All published, public entries from D1 with
<lastmod>dates - Tag pages that have at least one published public entry
/robots.txt returns a standard robots file that disallows /admin/ and points crawlers to the sitemap:
User-agent: *Allow: /Disallow: /admin/
Sitemap: https://example.com/sitemap.xmlBecause IndiePub themes use output: 'server', the official @astrojs/sitemap integration cannot discover dynamic routes. These built-in routes replace it entirely — you do not need @astrojs/sitemap or astro-robots-txt.
Virtual modules
indiepub:config — Resolved site config, embedded at build time. Use for values you need in component frontmatter without a DB query:
import { config } from 'indiepub:config';// config.title, config.siteUrl, config.syndication, etc.indiepub:content — Type-only module. Do not import runtime values from it. Access the content API at runtime via Astro.locals.indiepub.content:
// Types only — import like this:import type { ContentApi } from 'indiepub:content';
// Runtime access — use this in .astro files:const { content } = Astro.locals.indiepub!;const entries = await content.getEntries();Locals
In any Astro component or API route:
const { indiepub, member, isAdmin } = Astro.locals;
// indiepub.config — resolved IndiePubConfig (build-time)// indiepub.content — content API (getEntries, getEntry, getAuthor, …)// indiepub.logger — structured logger// indiepub.faviconUrl — favicon URL from site settings, or null// indiepub.accentColor — hex accent color from site settings (og_color), or null// indiepub.themePalette — palette preset name from site settings, or null// indiepub.stripeConnectedAccountId — Stripe Connect account ID, or null// indiepub.stripePriceId — Stripe Price ID, or null
// member — authenticated subscriber session, or null// isAdmin — true when a valid admin cookie is presentContent API
Astro.locals.indiepub.content exposes:
| Method | Description |
|---|---|
getEntries(type?, options?) | List entries, with optional type/tag/visibility/status filters and pagination |
getEntry(slug) | Single entry by slug |
getFeed(type?, options?) | Build a Feed object for RSS/Atom/JSON generation |
getAuthor(id?) | Primary author, or a specific author by ID |
getWebmentions(entryId) | Approved webmentions for an entry |
getProfiles() | All author profiles |
getInteractionCounts(entryId) | Like/comment/repost counts |
Middleware
The integration registers middleware (order: 'pre') that runs on every request:
- Reads the D1 binding from
context.locals.runtime.env - Fetches
debug_mode,favicon_url,og_color, andtheme_palettefromsite_settingsin a single query - Creates the content API and logger, attaches everything to
context.locals.indiepub - Parses member and admin session cookies, sets
context.locals.memberandcontext.locals.isAdmin - Calls
next(), then intercepts any404response to check theredirectstable — returns a301/302if a matching rule exists, otherwise returns the original 404
Environment variables
| Variable | Required | Description |
|---|---|---|
INDIEPUB_TOKEN | Yes | Bearer token for Micropub auth and admin fallback login |
BSKY_APP_PASSWORD | Bluesky | Bluesky app password |
ATPROTO_DID | Bluesky | Your ATProto DID (auto-resolved from handle if omitted) |
MASTODON_ACCESS_TOKEN | Mastodon | Mastodon API access token |
ACTIVITYPUB_PRIVATE_KEY | Mastodon | RSA-2048 private key PEM for HTTP Signatures |
ACTIVITYPUB_PUBLIC_KEY | Mastodon | RSA-2048 public key PEM |
RESEND_API_KEY | Subscriptions | Resend API key for newsletter and magic-link emails |
STRIPE_SECRET_KEY | Stripe | Site owner’s Stripe secret key (from Stripe Dashboard) |
STRIPE_WEBHOOK_SECRET | Stripe | Stripe webhook signing secret |
MEDIA_URL | Optional | Public R2 bucket base URL — maps to config.mediaUrl |