Documentation
Content's Not Dead is an open-source, cloneable content platform. Sell your own writing and art with Stripe subscriptions and per-item agent payments (MPP). The source is on GitHub(opens in a new tab).
1. Clone & install
Clone the repository(opens in a new tab) and install dependencies:
git clone https://github.com/bildungsroman/contents-not-dead.git my-content-sitecd my-content-sitenpm installcp .env.example .env.localPlanning to deploy your own site? Fork it(opens in a new tab) first and clone your fork instead, so you can push changes and connect the repo to Vercel. To keep pulling upstream fixes, add the original as a remote:
git remote add upstream https://github.com/bildungsroman/contents-not-dead.gitgit pull upstream main2. Configure environment
Fill in .env.local:
- Stripe —
STRIPE_SECRET_KEY(with Products, Prices, Checkout, Customers, Billing Portal, PaymentIntents, and Entitlements write access), then runnode --env-file=.env.local scripts/setup-stripe.mjsto create the entitlement features, the $0/mo free plan, and the $5/mo and $50/yr prices. Copy the printedSTRIPE_PRICE_MONTHLY,STRIPE_PRICE_ANNUAL, andSTRIPE_PRICE_FREEinto your env. The script is idempotent, so re-running it is safe. Live MPP payments also require the account’sSTRIPE_PROFILE_ID; sandbox demos may use the bundled internal fallback. - Clerk —
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYandCLERK_SECRET_KEY(free tier is fine). - MPP —
MPP_SECRET_KEY(stable, ≥32 bytes) andCONTENT_ASSET_SECRET. Generate withnode -e "console.log(require('crypto').randomBytes(32).toString('base64'))". - Tempo — optional.
TEMPO_DEPOSIT_ADDRESSis a Stripe crypto deposit address that lets agents pay in stablecoins on Tempo (testnet with a sandbox key).scripts/setup-stripe.mjsprints one if the key has Crypto Deposit Addresses access; leave it unset to accept cards and Link only. - Webhook — run
stripe listen --forward-to localhost:3000/api/stripe/webhookand setSTRIPE_WEBHOOK_SECRET. - Discovery — both optional.
NEXT_PUBLIC_API_URLis the public origin agents call; leave it unset to serve the agent surface fromNEXT_PUBLIC_APP_URL, and set it only when agents use a dedicated hostname. Paid-resource links in agent teasers and guides use this API origin, while subscription links stay on the app origin.MPP_CONTACT_EMAILis published in/openapi.jsonso you can verify ownership of the origin.
Using Stripe Projects(opens in a new tab)? Most of this is provisioned for you — run stripe projects env --pull.
3. Add your content
Drop Markdown files in content/ with frontmatter:
---title: My Postsummary: A one-line teaser shown on the home grid.authors: [Your Name]date: '2026-07-18'tags: [essays]type: article # or: imageaccess: paid # or: free — defaults to paid when omitted# image posts only:# image: my-art.png # full asset in content/assets/# preview: /previews/my-art.png # low-detail preview in public/--- Your Markdown body here.Full-resolution images live in content/assets/ (outside public/) and are served only after payment or a subscription via a short-lived signed URL. Put a low-detail preview in public/previews/. To place an image inside a post body, link it as  — that path is rewritten to the same signed, access-checked URL when the post renders.
4. Access tiers
The access field answers one question: what does a signed-in human need in order to read this post? It is not a “free to the world” switch — callers without a session pay per item either way. Omitting it means paid, so new content is never published by accident.
| Caller | access: free | access: paid |
|---|---|---|
| Not signed in (human or agent) | 402 → pay $0.50 | 402 → pay $0.50 |
| Signed in, free tier | full content | paywall / teaser |
| Signed in, paid tier | full content | full content |
The real dividing line is whether the caller has a session, not whether they’re human. An agent is how a person buys a single article without subscribing, so every paywall keeps pointing at the MPP flow.
Two Stripe entitlement features(opens in a new tab) do the gating: cnd_free_content and cnd_paid_content. The Free product ($0/month) grants the first; the Unlimited product grants both. Signing in subscribes you to the $0 plan automatically, so every signed-in user holds a real Stripe subscription and Checkout simply swaps it for a paid one. Because the mapping lives in Stripe, changing who can read what is a config change rather than a deploy.
What stays public: titles, summaries, and tags — agents need them to decide what’s worth buying — plus the low-detail images in public/previews/. Everything else needs an entitlement or a payment receipt.
5. Theming
A theme is a block of CSS variables keyed on a data-theme attribute set on <html>. Three ship in app/globals.css: minimalist, bookworm, and maximalist. Light and dark follow the browser automatically, and a data-scheme attribute can force one. Set the starting theme with NEXT_PUBLIC_DEFAULT_THEME; the in-app theme switcher lets visitors override it for their session.
Styles live in two places. app/globals.css holds the theme variables, base element styles, the page .container, and the .prose block that styles rendered Markdown, plus the shared .meta, .center, .warn, and .hidden utilities. Component-specific rules live in CSS Modules next to the components that own them, like components/PostCard.module.css.
Register the name
THEMES in lib/config.ts drives the Theme type and the switcher, so start there:
export const THEMES = [ "minimalist", "bookworm", "maximalist", "newsprint",] as const;Define the variables
Add the block to app/globals.css. These are every variable the components read — miss one and it falls back to the :root default:
[data-theme="newsprint"] { --font-body: "Iowan Old Style", Georgia, serif; --font-header: var(--font-body); --font-accent: var(--font-body); --bg: #fffdf7; /* page background */ --text: #1a1a1a; /* body copy */ --muted: #5f5f5f; /* .meta secondary text */ --border: #e0ddd3; --bg-header: #1a1a1a; /* header bar */ --text-header: #fffdf7; --accent: #9b1d20; /* links + primary buttons */ --accent-contrast: #ffffff; --overlay: #f2efe4; /* panels + footer */ --card-bg: #f7f5ed; /* post cards */ --card-text: #1a1a1a; --card-border: #e0ddd3; /* Optional: --radius, --maxw, --gap */ --radius: 2px;}Add the dark variant
Two blocks, because dark mode can arrive two ways. The first follows the operating system; the second handles the user explicitly choosing dark with the toggle. Override only the variables that actually change:
@media (prefers-color-scheme: dark) { [data-theme="newsprint"]:not([data-scheme="light"]) { --bg: #14130f; --text: #f2efe4; --card-bg: #1d1b16; }}[data-theme="newsprint"][data-scheme="dark"] { --bg: #14130f; --text: #f2efe4; --card-bg: #1d1b16;}Per-component tweaks (optional)
Most themes need nothing beyond variables. If yours has to change a specific component, put that rule in that component’s module rather than in app/globals.css. The data-theme attribute is global and the class is scoped locally, so the two combine normally:
/* components/PostCard.module.css */[data-theme="newsprint"] .postCard { border-width: 2px; box-shadow: 4px 4px 0 var(--border);}Watch out for one case in particular. The secondary Button variant is colored with --text, which assumes your header background looks like the page background. The example above inverts the header (--bg-header is dark while --bg is light), so the “Sign in” button turns dark-on-dark and vanishes. Give it the header colors explicitly:
/* components/SiteHeader.module.css */[data-theme="newsprint"] button.navButton { color: var(--text-header); border-color: var(--text-header);}6. Optional add-ons
Features that aren’t part of the core platform attach through lib/extras.ts instead of edits to the components themselves, so they survive upgrades. The registry ships empty:
// lib/extras.tsexport interface Extras { useExtraCards?: () => CardData[]; GridActions?: ComponentType; UnknownPost?: ComponentType<{ id: string }>;} export const extras: Extras = {};useExtraCards prepends cards to the home grid for content the server doesn’t store, GridActions renders controls beneath that grid, and UnknownPost takes over /post/[id] when the content store has no such id (the default is a “not found” panel). Assign whichever you need; the rest stay inert.
7. Agent parity
Every piece of content is equally available to agents — including posts marked access: free, which still cost $0.50 because the free tier is a perk for having an account rather than a public giveaway:
- /openapi.json — the canonical machine-readable contract
- /.well-known/mpp.json — payment config
- /llms.txt — LLM overview/index
- /agents — markdown directory
/api/content/{id}— paid endpoint (HTTP 402 → pay → full markdown)
/openapi.json is what registries and agent tooling resolve first, so it is the one to keep honest. Paid operations declare a 402 response plus an x-payment-info block carrying the price and the supported protocols; free operations carry security: [], which is what marks them as deliberately open rather than merely undeclared. The values come from the same constants the payment code charges, so the document cannot quietly drift from what the endpoint actually does.
/.well-known/mpp.json also describes each method independently under payment.method_details: Stripe uses the SPT/USD rail, while Tempo uses TIP-20 with its network, chain, token, and recipient. A single global rail would be ambiguous when both methods are enabled.
Payment failures use application/problem+json and include an actionable hint plus a charged assessment. Payment-service failures return 503; known transient provider failures also include Retry-After: 30.
Set the per-item price
This starter uses one global machine-payment price for every content item. Change PER_CONTENT_PRICE_USD in lib/config.ts:
export const PER_CONTENT_PRICE_USD = "0.50";Use a decimal USD amount. That constant drives the runtime challenge, agent guides, OpenAPI, and MPP discovery; mppx converts it to the minor units for Stripe and Tempo. Keep it at or above $0.50 while card/Link SPTs are enabled. Tempo stablecoins alone support amounts down to $0.01.
Pricing is global, not frontmatter-driven. Supporting a different price for each post requires adding a validated frontmatter field and passing it through chargeForContent and every discovery representation.
The runtime 402 is the final source of truth, and the part that most often goes wrong is the realm in the WWW-Authenticate header. It has to name the origin agents actually dialled. Left to its own devices the MPP library will resolve a host from the environment — on Vercel, the internal per-deployment hostname — so the realm is pinned to NEXT_PUBLIC_API_URL instead, and every discovery document above advertises that same origin. A proxy in front of the app must preserve the header and leave the realm alone.
8. Deploy
Deploy to Vercel. Set all env vars in the project settings and point NEXT_PUBLIC_APP_URL at your domain. Run scripts/setup-stripe.mjs against the production Stripe account so the features, products, and prices exist there too. Vercel Web Analytics and Speed Insights are already wired up.
Enable live machine payments
The demo starts with a Stripe sandbox. Supplying a live sk_live_... or rk_live_... key switches mppx to real Stripe SPT payments and, when a live deposit address is configured, Tempo mainnet (chain ID 4217, USDC.e). Keep test and live keys, MPP secrets, and deposit addresses in separate environments.
- In the Stripe Dashboard’s live mode, enable Stablecoins and Crypto under Payment methods and complete any requested review. Create a live restricted key with the app permissions listed above, plus Crypto Deposit Addresses read/write.
- Claim or configure the Stripe Business Profile, retrieve its
profile_...ID, and setSTRIPE_PROFILE_ID. Live Shared Payment Tokens are scoped to this profile. - Run
scripts/setup-stripe.mjswith the live environment and store the printed liveTEMPO_DEPOSIT_ADDRESSin Vercel. Never reuse the sandbox deposit address. - Deploy, make a small purchase, and confirm both the MPP receipt and its PaymentIntent in the live Stripe Dashboard.
curl https://api.stripe.com/v2/network/business_profiles/me \ -u "$STRIPE_SECRET_KEY:" \ -H "Stripe-Version: 2026-07-29.preview" node --env-file=.env.production scripts/setup-stripe.mjsnpx mppx@latest validate https://api.your-domain.comIf agents call a dedicated hostname, attach it to the deployment and set NEXT_PUBLIC_API_URL to it. A domain that resolves to Vercel but is not attached to the project answers every request with DEPLOYMENT_NOT_FOUND, which reads to a registry as an origin with nothing on it. Check both halves of discovery against the real origin before registering anywhere:
curl -s https://api.your-domain.com/openapi.json | headcurl -sI https://api.your-domain.com/api/content/<id> | grep -i www-authenticateThe realm in that header and the host you registered must be the same.
Configure a Stripe webhook endpoint at /api/stripe/webhook subscribed to checkout.session.completed, customer.subscription.created/updated/deleted, and entitlements.active_entitlement_summary.updated. That last event is what keeps access current — miss it and entitlement changes only reach the app through the slower revalidation fallback.