# How I built a paid membership site with Astro and Convex

> Build a paid membership site with Astro SSR, Convex, Resend magic links, Paddle Classic webhooks, and Cloudflare Workers. Step-by-step walkthrough.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-16 | Topics: [Astro](https://flaviocopes.com/tags/astro/) | Canonical: https://flaviocopes.com/membership-site/

I wanted a reusable membership starter, not another one-off product site.

The goal was simple. Sell access once. Let members log in without passwords. Gate Markdown content on the server. Accept payments through a webhook I could trust.

So I extracted the plumbing into a generic project called **Astro Membership Starter**. The site shell is [Astro](https://flaviocopes.com/astro/) with SSR on Cloudflare Workers. Membership data lives in Convex. Login emails go through Resend. Payments use Paddle Classic.

This tutorial walks through how I built it, stage by stage.

## The problem I was solving

A paid membership site needs more than a checkout button.

You need:

- a member record
- a login flow that does not leak who paid
- sessions that survive page loads
- content that stays hidden from logged-out visitors
- payment events that can arrive twice
- a deployment model that runs server routes at the edge

You can bolt these pieces onto a static marketing site. That gets messy fast. Auth logic drifts into client code. Member-only Markdown ends up in the public bundle. Webhook handlers become copy-pasted snippets with no shared secret discipline.

I wanted one small codebase with clear boundaries.

## Architecture before code

Here is the shape I settled on:

```text
Browser
  -> Astro SSR routes on Cloudflare Workers
       -> Convex (users, sessions, magic links, payments)
       -> Resend (magic link + welcome email)
  <- HTTP-only session cookie

Paddle Classic
  -> POST /api/purchase
       -> signature verify
       -> Convex member activate / revoke
       -> Resend welcome email
```

Three boundaries matter:

1. **SSR gating.** The server decides whether to render preview Markdown or the full body. The member-only text never ships in the logged-out HTML.
2. **Server secret.** Astro API routes call Convex with `AUTH_SERVER_SECRET`. Public clients never get that key.
3. **Webhook verification.** Paddle payloads are rejected unless the Classic signature checks out.

Convex holds the relational data. Astro holds rendering and HTTP. Resend delivers email. Paddle remains the billing system of record.

That split keeps the starter portable. I can swap Resend or replace Paddle Classic without rewriting the content model.

## Create the project

Start with a minimal Astro project:

```bash
npm create astro@latest astro-membership-starter -- --template minimal
cd astro-membership-starter
npx astro add cloudflare
npm install convex resend gray-matter sharp
```

Then create `.env.example` and `.env.local`.

The template documents the required variables. The local file holds their real values:

```bash
touch .env.example .env.local
```

The starter targets Node.js 22 or newer. Astro runs on port `4321` locally.

If you are new to Astro SSR, the free [Astro course](https://flaviocopes.com/courses/astro/) covers pages, layouts, and deployment basics. For Workers specifically, read [Cloudflare Workers](https://flaviocopes.com/cloudflare-workers/) first.

## Stage 1: Astro SSR shell on Cloudflare

The site uses server output, not a fully static build:

```js
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'

export default defineConfig({
  output: 'server',
  adapter: cloudflare({
    imageService: 'compile',
  }),
  server: {
    port: 4321,
  },
})
```

Every auth route, webhook route, and gated content page runs on the server. That is non-negotiable for membership gating. A static export would push access checks to the client, or force you into a separate API service.

Cloudflare Workers fit this model well. One Worker serves SSR HTML and API endpoints. The `@astrojs/cloudflare` adapter compiles Astro into that Worker.

Tradeoff: you pay for Worker invocations and you manage secrets with Wrangler. For a membership library with moderate traffic, that is usually fine. For a huge public blog with almost no dynamic routes, a static site plus a tiny API might be cheaper.

## Stage 2: Flat Convex schema with indexed relations

Convex stores four tables:

```ts
users          // email, isActive, optional paddleCustomerId
sessions       // token, userId, expiresAt
magicLinks     // email, token, expiresAt, used
payments       // userId, paddleOrderId, amount, status
```

Each table is flat. Relationships use Convex IDs, not nested documents.

Indexes cover the lookup paths the app actually uses:

```text
users.by_email
users.by_paddleCustomerId
sessions.by_token
sessions.by_userId
magicLinks.by_token
payments.by_userId
```

I kept the schema small on purpose. Membership sites usually need users, sessions, and payment history. They rarely need a forum, comments, and audit logs on day one.

You can extend later. Add `accessRequests`, `teams`, or `roles` when the product demands it. The starter intentionally stops at one access flag: `isActive`.

Poor fit: multi-tenant B2B with complex RBAC, per-seat billing, or heavy analytics inside the same database. Convex can handle that, but this starter will feel tight quickly.

## Stage 3: Server-secret boundary

Convex functions are callable from the public internet unless you guard them.

Every server-only query and mutation in the starter takes `serverSecret` and calls `requireServerSecret()`:

```ts
export function requireServerSecret(serverSecret: string) {
  const expected = process.env.AUTH_SERVER_SECRET

  if (!expected || serverSecret !== expected) {
    throw new Error('Unauthorized')
  }
}
```

Astro reads the same value through `getAuthServerSecret()` and passes it on every Convex call from API routes and middleware.

Why this pattern exists:

- The Astro Worker is trusted backend code.
- The browser is not.
- You do not want anonymous clients listing members or creating sessions.

Set the secret in two places:

```bash
# local Astro
AUTH_SERVER_SECRET=replace-with-a-long-random-value

# Convex deployment
npx convex env set AUTH_SERVER_SECRET replace-with-a-long-random-value
```

If they drift, login works in one environment and fails in another. Keep them identical.

Tradeoff: this is shared-secret auth between services, not end-user JWT validation inside Convex. For this starter, that is the right complexity level. If you later expose Convex directly to browsers, add proper Convex auth or keep sensitive mutations server-only.

## Stage 4: Resend magic links

Password login was out. I wanted email links with short lifetimes.

The login form posts to `/api/auth/send`. The route:

1. normalizes the email
2. loads the user with `getLoginUserByEmail`
3. returns `{ ok: true }` even when the user is missing or inactive
4. creates a magic link token in Convex
5. sends the email through Resend

The generic response matters. Without it, an attacker can probe which emails bought access.

For active members, the route generates a random token and stores it:

```ts
const token = crypto.randomBytes(32).toString('hex')

await convex.mutation(anyApi.auth.createMagicLink, {
  email,
  token,
  serverSecret,
})
```

Then it builds a login URL and sends HTML email:

```ts
const loginUrl = new URL('/api/auth/verify', getSiteUrl(new URL(request.url)))
loginUrl.searchParams.set('token', token)
loginUrl.searchParams.set('next', next)

await new Resend(resendKey).emails.send({
  from: getEmailFrom(),
  to: email,
  subject: `Your ${SITE_NAME} login link`,
  html: `<p>Use this link to log in:</p><p><a href="${loginUrl}">${loginUrl}</a></p>`,
})
```

Configure `EMAIL_FROM` with a verified Resend domain, for example `members@your-domain.com`.

The login page uses [HTMX](https://flaviocopes.com/htmx-introduction/) for a small UX improvement. The form submits without a full page reload. The success message stays generic: "If that account is active, check your inbox."

## Stage 5: Scanner-safe confirmation

Email security scanners follow links before the human does. If the first GET consumed the token, real members would hit an expired link.

The verify route uses a two-step flow:

```text
GET /api/auth/verify?token=...        -> confirmation HTML page
GET /api/auth/verify?token=...&confirm=1 -> create session, redirect
```

First visit renders a simple confirmation page with a "Log in" link. Only the explicit confirmation creates the session.

Inside `verifyMagicLink`, the token is marked used and checked against expiry:

```ts
if (!magicLink || magicLink.used || magicLink.expiresAt < args.now) {
  return null
}

await ctx.db.patch(magicLink._id, { used: true })
```

Magic links expire after 30 minutes. Sessions last 30 days.

Tradeoff: one extra click after the email link. That is worth it for reliable login in corporate inboxes.

## Stage 6: HTTP-only sessions in middleware

After confirmation, the verify route creates a session row in Convex and sets two cookies:

```ts
cookies.set('session_token', sessionToken, { httpOnly: true, ... })
cookies.set('logged_in', '1', { httpOnly: false, ... })
```

`session_token` is the real credential. `logged_in` is a readable marker for UI toggles only. Never trust `logged_in` for authorization.

`src/middleware.ts` loads the member on every request:

```ts
const user = await getConvexClient().query(anyApi.auth.getSessionUser, {
  token: sessionToken,
  now: Date.now(),
  serverSecret: getAuthServerSecret(),
})

if (user?.isActive) {
  context.locals.user = {
    _id: user.userId,
    email: user.email,
    name: user.name,
    isActive: user.isActive,
  }
} else {
  context.cookies.delete('session_token', { path: '/' })
  context.cookies.delete('logged_in', { path: '/' })
}
```

If the session expired or the member was revoked, stale cookies disappear automatically.

This follows the same idea as [Astro middleware execution order](https://flaviocopes.com/astro-page-layout-and-middleware-execution-order/). Middleware runs before the page. Gated routes read `Astro.locals.user`, not cookies directly.

Logout deletes the Convex session server-side and clears both cookies.

## Stage 7: Local Markdown loader and SSR gating

Member content lives as plain Markdown:

```text
src/content/guides/
src/content/articles/
```

Each file requires `title`, `description`, and `preview` in frontmatter. The loader reads files at build time with `import.meta.glob`:

```ts
const guideFiles = import.meta.glob<string>('../content/guides/*.md', {
  eager: true,
  query: '?raw',
  import: 'default',
})
```

The guide route chooses what to render:

```astro
const isMember = Boolean(Astro.locals.user?.isActive)

<Fragment set:html={renderMarkdown(isMember ? entry.body : entry.preview)} />
```

Logged-out visitors see `preview`. Active members see the full `body`. The gate banner links to `/login?next=...`.

Why SSR gating instead of client-side blur:

- View source cannot reveal member lessons.
- You do not need a separate JSON API for content.
- SEO previews stay honest.

Tradeoff: Markdown changes require a rebuild/redeploy. For a membership library that updates weekly, that is acceptable. For hourly publishing, move content into a CMS or Convex tables.

The included renderer is intentionally tiny. It handles headings, lists, fenced code, and links. For richer Markdown, plug in `marked`, `remark`, or Astro content collections.

## Stage 8: Paddle Classic signed webhook

Payments do not belong in the login form. Paddle sends server-to-server events.

Point the Classic webhook at:

```text
https://members.your-domain.com/api/purchase
```

The handler parses form-encoded payloads, verifies `p_signature`, and routes by `alert_name`:

```ts
if (['payment_succeeded', 'payment_completed', 'checkout_completed'].includes(payload.alert_name)) {
  return activate(payload)
}

if (['payment_refunded', 'refund_issued', 'payment_dispute_created'].includes(payload.alert_name)) {
  return revoke(payload)
}
```

Signature verification rebuilds Paddle's PHP-serialized payload and checks RSA-SHA1 with your public key. Invalid signatures return `403`.

Activation checks the product ID before granting access:

```ts
if (!configuredProductId || productId !== configuredProductId) {
  return json(200, { ok: true, skipped: 'unknown_product' })
}
```

Then it upserts the member and checks `paddleOrderId` before recording the payment:

```ts
if (!payments.some((payment) => payment.paddleOrderId === orderId)) {
  await convex.mutation(anyApi.payments.recordPayment, { ... })
  await sendWelcomeEmail(email)
}
```

That duplicate guard matters because [webhooks can repeat](https://flaviocopes.com/webhooks/).

This is enough for sequential retries, but it is not a complete idempotency guarantee. Two webhook requests could pass the lookup at the same time. Before handling meaningful payment volume, I would store webhook event IDs in a dedicated table and claim each ID inside one mutation.

Refunds and disputes set `isActive: false`.

Important limitation: this handler targets **Paddle Classic**, not Paddle Billing. Billing uses different payloads and verification. Swap `src/lib/purchaseWebhook.ts` if your account is on Billing.

Poor fit: subscriptions with complex plan changes, tax invoicing inside your app, or marketplaces with split payouts. The starter handles one product and one-time access well.

## Stage 9: Cloudflare Worker config

`wrangler.jsonc` deploys the compiled Astro Worker:

```jsonc
{
  "name": "astro-membership-starter",
  "main": "@astrojs/cloudflare/entrypoints/server",
  "compatibility_date": "2026-07-15",
  "compatibility_flags": ["nodejs_compat"],
  "vars": {
    "SITE_URL": "https://members.your-domain.com"
  },
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS"
  }
}
```

`nodejs_compat` matters because Paddle signature verification uses Node's `crypto` module.

`SITE_URL` must match your production origin. Magic links and welcome emails depend on it.

Secrets do not belong in `wrangler.jsonc`. Push them with Wrangler:

```bash
npx wrangler secret put CONVEX_URL
npx wrangler secret put AUTH_SERVER_SECRET
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put EMAIL_FROM
npx wrangler secret put PADDLE_PUBLIC_KEY
npx wrangler secret put PADDLE_PRODUCT_ID
```

Deploy with:

```bash
npm run build
npm run deploy
```

Add the custom domain in Cloudflare after the first deploy.

## Stage 10: Environment setup

Local development needs Convex running:

```bash
npx convex dev
```

That connects a development deployment and syncs your functions.

Make sure `.env.local` contains the `CONVEX_URL` printed for that deployment.

Minimum variables:

| Variable | Purpose |
| --- | --- |
| `CONVEX_URL` | Convex deployment used by Astro |
| `AUTH_SERVER_SECRET` | Protects server-only Convex functions |
| `RESEND_API_KEY` | Sends magic links and welcome mail |
| `EMAIL_FROM` | Verified sender, e.g. `members@your-domain.com` |
| `SITE_URL` | Canonical origin for email links |
| `PADDLE_PUBLIC_KEY` | Verifies Classic webhook signatures |
| `PADDLE_PRODUCT_ID` | Limits grants to your product |
| `PUBLIC_PADDLE_CHECKOUT_URL` | Optional checkout button on `/join` |

Never commit `.env.local`. Production secrets live in Wrangler.

To create a member without Paddle, call `users.upsertMember` from the Convex dashboard:

```json
{
  "email": "member@your-domain.com",
  "isActive": true,
  "serverSecret": "replace-with-a-long-random-value"
}
```

Then request a magic link from `/login`.

## Stage 11: Verification

I verify the starter in this order:

1. **Convex dev connected.** `npx convex dev` shows the deployment URL. Dashboard functions respond.
2. **Manual member.** Upsert a test user. Request a magic link. Confirm the two-step verify flow creates a session.
3. **Gated content.** Open a guide logged out. View source should contain only the preview. Log in. Full body appears.
4. **Logout.** Session row deletes in Convex. Reopening a gated page shows the preview again.
5. **Webhook dry run.** Use Paddle's webhook simulator with your public key configured. Signature failure returns `403`. Valid test payload activates the member once.
6. **Refund path.** Send a refund alert. Member loses access on the next request.
7. **Production deploy.** Hit `https://members.your-domain.com/login` over HTTPS. Cookies must be `Secure` in production.

If magic links fail locally, check Resend domain verification first. If webhooks fail in production, compare `SITE_URL`, product ID, and the public key formatting with escaped newlines.

## Tradeoffs and where this starter is a poor fit

What this starter optimizes for:

- solo creators selling one library
- Markdown-first content
- passwordless login
- Paddle Classic one-time purchases
- small team maintenance burden

Where I would not start from it:

- **Large free tier plus tiny paid upsell.** You still pay SSR costs on public pages.
- **Native mobile apps.** They need token APIs, not cookie sessions tied to a web domain.
- **Enterprise SSO.** You will replace the magic-link flow entirely.
- **Complex subscriptions.** Plan upgrades, metered billing, and dunning belong in a billing platform integration layer this project does not include.
- **Real-time collaboration.** Convex can do it, but the content model here is static Markdown, not shared documents.

The starter is a foundation. It is not a full learning platform, community product, or marketplace.

## A prompt to generate this site

You do not need a zip file to start from this project.

Create an empty folder, open it with your coding agent, and paste this prompt:

```text
Build a generic paid membership site called "Member Library".

Use this stack:

- Astro with server-side rendering
- TypeScript in strict mode
- the Astro Cloudflare adapter
- Convex for users, sessions, magic links, and payments
- Resend for transactional email
- Paddle Classic for one-time payments
- local Markdown files for member content
- HTMX only for small form interactions

Keep the project small. Do not add React.

The site needs these pages:

- `/` with a short product introduction
- `/library` with guides and articles
- `/guides/[slug]` for guide pages
- `/articles/[slug]` for article pages
- `/login` for passwordless login
- `/join` for the Paddle checkout link
- `/thanks` after checkout

Create Astro API routes for:

- sending a Resend magic link
- confirming the magic link
- logging out
- receiving Paddle Classic webhooks

Use a flat Convex schema with separate tables for:

- users
- sessions
- magic links
- payments
- processed webhook events

Add indexes for every lookup path.

Every public Convex function must validate its arguments and return value.
Protect server-only Convex functions with `AUTH_SERVER_SECRET`.
Never expose this secret to browser code.

The login endpoint must return the same success response for known and unknown
emails. Add rate limits by email and IP address.

Magic links expire after 30 minutes and can only be used once. The first
email-link request must show a confirmation page without consuming the token.
Create the session only after the member confirms. Sessions expire after 30
days. Store the real session token in an HTTP-only, Secure, SameSite=Lax
cookie.

Accept an optional `next` path after login, but only allow a local path that
starts with one `/`. Reject protocol-relative URLs, backslashes, and different
origins to prevent open redirects.

Load the session in Astro middleware. Put the active member in
`Astro.locals.user`.

Each Markdown file must have:

- title
- description
- preview

Logged-out visitors can only receive the preview HTML. Render the complete
Markdown body on the server only after checking the active member. Do not send
member content in page data, client-side JavaScript, hidden HTML, or a public
JSON endpoint.

Verify every Paddle webhook signature before changing data. Check the configured
product ID. Claim each webhook event ID in one Convex mutation so retries and
concurrent deliveries cannot grant access twice. Activate access after a valid
payment. Revoke access after a refund or dispute.

Create `.env.example` with placeholders only. It must list:

- CONVEX_URL
- AUTH_SERVER_SECRET
- RESEND_API_KEY
- EMAIL_FROM
- EMAIL_REPLY_TO
- SITE_URL
- PADDLE_PUBLIC_KEY
- PADDLE_PRODUCT_ID
- PUBLIC_PADDLE_CHECKOUT_URL

Never create or print real credentials. Never read credentials from unrelated
projects or shell history. Keep `.env`, `.env.local`, build output, and local
deployment files out of Git.

Add two short sample Markdown files. Add a README that explains local setup,
`npx convex dev`, Resend configuration, Paddle webhook configuration, testing,
and Cloudflare Workers deployment.

Run the TypeScript checks and production build. Fix every error before stopping.
Finish with a list of files created, tests run, and values I still need to
configure.
```

This prompt describes the behavior and the security boundaries.

It also asks for two improvements beyond my first starter: rate limits on login emails and an atomic webhook-event claim.

The agent still needs supervision. Read the generated webhook and access checks before connecting a real checkout.

## How I would use this starter

If I spun up another paid library tomorrow, I would start here and customize in a fixed order.

First, rename the product in `src/lib/config.ts` and replace the sample guides in `src/content/`. Keep the preview field useful. Tease the outcome, not the middle lessons.

Second, wire Resend with a dedicated subdomain like `members@your-domain.com`. Warm up that domain before launch week.

Third, connect Paddle Classic checkout on `/join` and set the success URL to `/thanks`. Point the webhook at `/api/purchase`. Test refund revocation before announcing the product.

Fourth, adjust gating rules if needed. The default is binary: active members see everything. For a course-style drip, I would add `publishedAt` fields or move content into Convex and query by entitlement.

Fifth, deploy to Cloudflare Workers and set `SITE_URL` to the real origin. I would keep marketing pages on a separate static site if SEO volume matters more than shared auth cookies.

I would not change the auth stack unless the requirements forced me. The weak point is usually the content workflow, not login.

For a developer newsletter plus paid archive, this starter is close to ideal. For a team SaaS with roles and invoices, treat it as a reference implementation and plan a longer roadmap.

The codebase stays generic on purpose. Rename it, replace the sample content, and ship your library.
