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.local

Planning 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 main

2. Configure environment

Fill in .env.local:

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 ![alt](/content/assets/my-diagram.png) — 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.

Calleraccess: freeaccess: paid
Not signed in (human or agent)402 → pay $0.50402 → pay $0.50
Signed in, free tierfull contentpaywall / teaser
Signed in, paid tierfull contentfull 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.

While developing, LOCAL_FULL_ACCESS=true (the default) unlocks the website for localhost requests. It deliberately does not apply to /api/content/* or /agents/*, so the shortcut can never hand an agent content it should have paid for. Set it to false to exercise the real paywall.

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);}

The button element qualifier is deliberate — it raises specificity just enough to beat Button.module.css regardless of which stylesheet the bundler emits first. Note also that a selector can only reference classes from its own module; to style a component defined elsewhere, pass a class through its className prop, which is exactly what SiteHeader does with navButton.

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.

useExtraCards is called on every render of the grid, so it must obey the rules of hooks. This is how the contentsnotdead.com deployment adds AI generation without carrying a patch against core.

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 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.

  1. 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.
  2. Claim or configure the Stripe Business Profile, retrieve its profile_... ID, and set STRIPE_PROFILE_ID. Live Shared Payment Tokens are scoped to this profile.
  3. Run scripts/setup-stripe.mjs with the live environment and store the printed live TEMPO_DEPOSIT_ADDRESS in Vercel. Never reuse the sandbox deposit address.
  4. 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.com

Live validation and tempo request can move real funds.

If 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-authenticate

The 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.

The contentsnotdead.com deployment adds AI content generation on top of this codebase. It lives in a separate private repository and is not part of the open-source app.