Imported from cloudgrid-io/mcp (
src/corpus/templates/membership-site/AGENTS.md). Install upstream withnpx skills add cloudgrid-io/mcp --skill membership-site. Copyright stays with the author.
AGENTS.md — membership-site (BLUEPRINT)
This is a blueprint, not runnable code. It tells an agent how to build a
paid membership site correctly on CloudGrid: a Next.js app with auth, Stripe
subscription checkout, gated content routes that check membership status, and
Mongo for persistence. Read this whole file, then build the app under
services/web/ following the structure below. Do not skip the CloudGrid wiring
rules — they are what make it deploy.
The proven persistent shape is app-with-data (Next.js + Mongo). This blueprint
extends it with auth + Stripe. Fetch that template for the Mongo lazy-client and
App-Router API pattern: grid_get_template("template", "app-with-data").
1. File tree
Build exactly this layout. App code MUST live under services/web/ — the
service is named web, so the CLI looks for services/web/. path: / in
cloudgrid.yaml is the URL mount, NOT the filesystem path. Files at the repo
root fail with Error: Service directory not found: …/services/web.
cloudgrid.yaml # name + services.web (nextjs) + needs:{database:true} + vault: (Stripe/auth secrets)
services/web/package.json # next, react, react-dom, mongodb, stripe, + auth SDK (e.g. @clerk/nextjs)
services/web/middleware.js # auth middleware: protects /members/* — redirects anon to sign-in
services/web/lib/db.js # lazy Mongo client from DATABASE_MONGODB_URL (legacy MONGODB_URL fallback)
services/web/lib/stripe.js # lazy Stripe client from process.env.STRIPE_KEY (never top-level)
services/web/lib/membership.js # getMembership(userId): read status from the memberships collection
services/web/app/layout.js # root layout (+ auth provider wrapper if the SDK needs one)
services/web/app/page.js # public marketing / paywall landing page + "Subscribe" CTA
services/web/app/members/layout.js # gate: server-side check membership status; redirect non-members to /pricing
services/web/app/members/page.js # gated subscriber content (only active members reach it)
services/web/app/pricing/page.js # plans + "Subscribe" button → POST /api/checkout
services/web/app/api/checkout/route.js # POST: create a Stripe Checkout Session (mode: subscription) → return url
services/web/app/api/webhook/route.js # POST: Stripe webhook — upsert membership status on subscription events
services/web/app/api/me/route.js # GET: current user's membership status (for client components)
2. Mongo collections + fields
The grid provisions Mongo from needs: { database: true }. Use two collections:
users — mirror of the auth provider's user (only if you store profile data
locally; many auth SDKs own the identity, so this may be optional).
_id(ObjectId)authId(string) — the auth provider's user id (Clerk/Auth0sub)email(string)createdAt(Date)
memberships — the source of truth for who is a paying member. Gating reads
this, and the Stripe webhook writes it.
_id(ObjectId)authId(string, indexed) — links to the signed-in userstripeCustomerId(string)stripeSubscriptionId(string)plan(string) — e.g."monthly","annual"status(string) —"active" | "trialing" | "past_due" | "canceled"currentPeriodEnd(Date) — when access lapses if not renewedupdatedAt(Date)
A user is a member when a memberships doc for their authId has
status ∈ {active, trialing} and currentPeriodEnd is in the future.
3. How CloudGrid injects everything (the wiring that matters)
- Mongo — declared by
needs: { database: true }. The deployer provisions shared Mongo and injects the connection string asDATABASE_MONGODB_URL(plus the legacyMONGODB_URLalias) at runtime and undergrid dev. Readprocess.env.DATABASE_MONGODB_URL || process.env.MONGODB_URLLAZILY, inside the getter inlib/db.js— never at module top level, ornext buildfails when it imports the module for route analysis before the grid injects the var. Never hardcode a connection string. Never setneeds:andrequires:together —requires:is the deprecated v1 alias. - Secrets (Stripe + auth) → env vars via the
vault:block. Incloudgrid.yamlthevault:block maps each env var to a vault item key:
Set the vault items ONCE withvault: STRIPE_KEY: stripe-live-key STRIPE_WEBHOOK_SECRET: stripe-webhook-secret AUTH_PROVIDER_KEY: auth-provider-keygrid secrets set(orgrid secrets set), then the deployer injects each as the named env var (process.env.STRIPE_KEY, etc.) at runtime and undergrid dev. Do NOT commit keys; do NOT set them inservices.web.env(that block is for non-secret config only). Read them lazily insidelib/stripe.jsand the auth setup — same rule as the DB. - Public auth keys — SDKs like Clerk also need a publishable key on the
client (
NEXT_PUBLIC_...). Publishable keys are not secret; put them in the non-secretservices.web.envblock (or as a build-time env) rather than the vault. - AI (optional) — if you add AI features, declare
needs: { ai: true }and call the gateway atprocess.env.RUNTIME_GATEWAY_URLvia@cloudgrid-io/runtime. Not required for this blueprint.
4. Wiring auth + payments
Auth (provider SDK, e.g. Clerk or Auth0).
- Add the SDK to
services/web/package.json(e.g.@clerk/nextjs). - Server key from the vault (
process.env.AUTH_PROVIDER_KEY); publishable key fromservices.web.envasNEXT_PUBLIC_.... - Wrap the app in the provider in
app/layout.js. services/web/middleware.jsprotects/members/*(and/api/checkout) — the SDK's middleware redirects unauthenticated users to sign-in. Auth answers "who are you"; it does NOT answer "have you paid" — that is the membership check below.
Payments (Stripe subscriptions).
- Add
stripetopackage.json. Build a lazy Stripe client inlib/stripe.jsreadingprocess.env.STRIPE_KEYinside the getter. - Checkout —
app/api/checkout/route.js(POST): require an authenticated user (from the auth SDK), create a Stripe Checkout Session withmode: "subscription"and your price id, setsuccess_url/cancel_url, stashauthIdin the sessionmetadata, returnsession.url. The pricing page's Subscribe button POSTs here and redirects the browser to the returned URL. - Webhook —
app/api/webhook/route.js(POST): the source of truth for membership. Verify the signature withprocess.env.STRIPE_WEBHOOK_SECRET(read the RAW request body — do not JSON-parse before verifying). Oncheckout.session.completed,customer.subscription.updated, andcustomer.subscription.deleted, upsert themembershipsdoc keyed byauthIdwith the newstatus/currentPeriodEnd. Register this route's public URL as the webhook endpoint in the Stripe dashboard after deploy.
Gating (the membership check).
lib/membership.jsexportsgetMembership(authId)→ reads themembershipscollection and returns whether the user is active.app/members/layout.jsis a server component: get the signed-inauthId, callgetMembership, and if not activeredirect("/pricing"). Every route under/members/*is gated by this layout — content only renders for paying members. Do the check server-side; never gate purely on the client.
5. Deploy steps
grid_login_status→grid_loginif needed. Respect the grid picker (ask which grid if the user has more than one).grid new <name>— it scaffolds the project folder and acloudgrid.yamlwith emptyservices: {}. No server entity exists yet — the first plug auto-creates it from the manifest (honoring itsname:) and writes.cloudgrid/link.json.- Write the app under
services/web/and setcloudgrid.yamlto the active shape:name+services.web{type: nextjs, path: /}+needs:{database:true}- the
vault:block.
- the
- Set the secrets:
grid secrets setforstripe-live-key,stripe-webhook-secret,auth-provider-key(the vault item keys thevault:block maps from). Non-secret config (publishable auth key, price id) →grid env set/services.web.env. Do NOT setDATABASE_MONGODB_URLyourself — the grid injects it. grid_plugto deploy. A runtime deploy is ASYNC — the first response isstatus: building, not a live URL. Pollgrid_check_deploy(or the returned poll_url) until live; surface a liveness signal while it builds, never a bare silent wait.- Once live, add the
/api/webhookURL as the Stripe webhook endpoint and copy the signing secret into thestripe-webhook-secretvault item, then re-plug. Return the live app URL (not the build/log link).
6. Edition note
A membership site is a built + deployed runtime container, so it requires the
local edition (Claude Desktop / Claude Code) or the CLI. The hosted
edition (Claude Web / hosted MCP) is inline-only and can only publish static
pages — it CANNOT build this app. On hosted, say so plainly and offer a static
paywall-landing page (via grid_plug) instead, then stop the runtime path.