Skip to content

@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

Terminal window
pnpm add @indiepub/astro

Usage

astro.config.mjs
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

RouteDescription
GET /media/[...path]Serve R2 media uploads (dev and prod fallback)
GET /og/[slug]Generated Open Graph PNG for an entry
GET/POST /micropubMicropub endpoint
GET/POST /.well-known/webmentionWebmention endpoint
GET /feed.xmlRSS 2.0 feed
GET /feed.atomAtom 1.0 feed
GET /feed.jsonJSON Feed
GET /.well-known/nodeinfoNodeInfo discovery redirect
GET /nodeinfo/2.0NodeInfo 2.0 document
GET /sitemap.xmlDynamic XML sitemap (static pages + published entries + tag pages)
GET /robots.txtRobots.txt with Sitemap: directive and /admin/ disallow
POST /admin/uploadUpload an image to R2 (admin-authenticated)
POST /admin/composeQuick-post from the inline note composer
GET /admin/magicMagic link handler for admin login

Mastodon / ActivityPub (when syndication.mastodon is set)

RouteDescription
GET /.well-known/webfingerWebFinger identity discovery
GET /actorActivityPub Actor document
POST /actor/inboxActivityPub inbox
GET /actor/outboxActivityPub outbox
GET /actor/followersFollowers collection
GET /actor/followingFollowing collection

Bluesky / ATProto (when syndication.bluesky is set)

RouteDescription
GET /.well-known/atproto-didATProto DID document
GET /.well-known/site.standard.publicationstandard.site publication record

Subscriptions (when subscriptions.enabled is true)

RouteDescription
POST /api/subscribeSubscribe with an email address
GET /api/unsubscribeUnsubscribe via token
GET /auth/loginMember magic-link login form
GET /auth/magicMagic link verification handler
GET /auth/logoutClear member session

Stripe Connect (when subscriptions.stripe is set)

RouteDescription
GET /stripe/checkoutCreate a Stripe Checkout session and redirect. On ?success=1, upgrades the subscriber tier.
POST /stripe/webhookStripe webhook handler — verifies signature, processes checkout.session.completed and customer.subscription.deleted events
GET /stripe/portalRedirect 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)

  1. Enable Stripe in your integration config:
indiepub({
subscriptions: {
enabled: true,
stripe: {}, // mode defaults to 'direct'
},
})
  1. In your Stripe Dashboard → API keys, copy your secret key. Set STRIPE_SECRET_KEY as an environment variable on your Cloudflare Worker.

  2. 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_SECRET as an environment variable.
  3. In your Stripe Dashboard → Products, create a product with a recurring price. Copy the Price ID.

  4. Go to /admin/stripe and paste the Price ID.

Setup — Connect mode (hosted platforms)

indiepub({
subscriptions: {
enabled: true,
stripe: { mode: 'connect' },
},
})
  1. Go to /admin/stripe and click Connect with Stripe to link your account via OAuth.
  2. Create a recurring Price in your Stripe Dashboard and paste the Price ID in the admin panel.
  3. Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET environment variables.

How paid content gating works

Entries have a visibility field with five levels: public, unlisted, members, paid, and private.

  • paid entries are visible only to subscribers with tier: 'paid' (i.e. active Stripe subscriptions)
  • members entries are visible to all logged-in subscribers (free and paid)
  • Feeds (/feed.xml, etc.) exclude paid entries — they only include public entries

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:resolved hook (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.xml

Because 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 present

Content API

Astro.locals.indiepub.content exposes:

MethodDescription
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:

  1. Reads the D1 binding from context.locals.runtime.env
  2. Fetches debug_mode, favicon_url, og_color, and theme_palette from site_settings in a single query
  3. Creates the content API and logger, attaches everything to context.locals.indiepub
  4. Parses member and admin session cookies, sets context.locals.member and context.locals.isAdmin
  5. Calls next(), then intercepts any 404 response to check the redirects table — returns a 301/302 if a matching rule exists, otherwise returns the original 404

Environment variables

VariableRequiredDescription
INDIEPUB_TOKENYesBearer token for Micropub auth and admin fallback login
BSKY_APP_PASSWORDBlueskyBluesky app password
ATPROTO_DIDBlueskyYour ATProto DID (auto-resolved from handle if omitted)
MASTODON_ACCESS_TOKENMastodonMastodon API access token
ACTIVITYPUB_PRIVATE_KEYMastodonRSA-2048 private key PEM for HTTP Signatures
ACTIVITYPUB_PUBLIC_KEYMastodonRSA-2048 public key PEM
RESEND_API_KEYSubscriptionsResend API key for newsletter and magic-link emails
STRIPE_SECRET_KEYStripeSite owner’s Stripe secret key (from Stripe Dashboard)
STRIPE_WEBHOOK_SECRETStripeStripe webhook signing secret
MEDIA_URLOptionalPublic R2 bucket base URL — maps to config.mediaUrl