Recipe · checked November 13, 2026
Consumer web app with accounts and an optional paid plan
A web app for individual people, one person per account: a habit tracker, a journal, a budgeting tool, a recipe box, a hobby community. Next.js on Cloudflare Workers through the OpenNext adapter, Postgres on Neon, Better Auth with users in your own database, Stripe for a free-plus-paid model, and Resend for sign-in and account email.
Fits when
- Each account belongs to one person, not to a company or team.
- It runs in a web browser (a PWA counts); phone apps are a separate recipe.
- It's free to start, with an optional monthly or yearly paid plan, or no payments at all.
Not for
- Products sold to companies, where several colleagues share an account (see the b2b-saas recipe).
- iOS or Android apps that sell subscriptions inside the app (see the mobile-subscription-app recipe).
- Products where users pay each other (see the marketplace recipe), and AI apps that answer from documents (see the ai-docs-app recipe).
- Live collaboration (shared whiteboards, multiplayer editing): you need a realtime service this recipe doesn't set up.
- Apps that store health, banking or children's data: the stack works, but read the compliance notes first.
The stack
| Category | Pick | Why | Pick something else when |
|---|---|---|---|
| Hosting | Cloudflare Workers | Next.js runs on Workers through the OpenNext adapter (@opennextjs/cloudflare), static files are served free, and Workers Paid is $5/month for 10 million requests, so a consumer app that gets popular stays cheap. There are no data transfer charges. | |
| Database | Neon | Postgres that scales to zero, with a serverless driver that connects from Workers over HTTP, and database branches for previews. |
|
| Auth | Better Auth | Open-source sign-in that keeps users in your own Postgres, with no per-user fees: a consumer app can grow to a million free accounts without an auth bill. |
|
| Payments | Stripe | Checkout in subscription mode for the paid plan, and the hosted customer portal so people can cancel or change card without writing to you. |
|
| Resend | Verification, magic-link and password-reset email from your own domain, with React Email templates. |
| |
| Monitoring | Sentry | Error alerts with readable stack traces, so you hear about a broken sign-up before users give up. |
|
| Analyticsoptional | PostHog | Sign-up funnel, how many people come back in week two, and which free users upgrade. |
|
| Storageoptional | Cloudflare R2 | Profile photos and user uploads on the same Cloudflare account, with no egress fees. |
|
About $25/month at 1,000 users in tool bills: Resend $20 + Cloudflare Workers $5; $0 for Neon, Better Auth, Stripe, Sentry. Workers Paid from launch ($5/month, 10M requests included): Free's 10 ms of CPU per request is tight for server-rendered pages. Usage profile: 1,000 monthly active users with light use: about 100,000 requests, 3,000 emails, 200,000 analytics events and 5 GB of stored files a month, and a database under 1 GB. AI apps add 50 questions per user a month at about 4,000 input and 500 output tokens each (200M input and 25M output tokens). Internal tools are priced for 10 staff instead.
Catches that change the plan
- Workers Free stops at 100,000 requests a day (error 1027) and allows 10 ms of CPU per request; Workers Paid is $5/month for 10M requests, then $0.30 per million. Move to Paid before launch. Source
- Next.js on Workers runs through the OpenNext adapter, which doesn't support the edge runtime or Node middleware yet, and the Worker's gzip size must stay under 3 MiB on Free and 10 MiB on Paid. Source
- Stripe isn't a merchant of record: registering for and filing sales tax and VAT where your customers live is yours. Paddle files it for you for 5% + 50¢ per sale. Source
- Resend's free plan sends 3,000 emails a month but only 100 a day, with no overage: a launch day of sign-up verifications passes it. Pro is $20/month for 50,000. Source
Setup steps
Scaffold Next.js for Cloudflare Workers
Create the app with Cloudflare's template, which sets up the OpenNext adapter, wrangler.jsonc and the preview and deploy scripts.
- Create the app with the command below and pick TypeScript and the App Router.
- Check that wrangler.jsonc has the nodejs_compat flag and a compatibility_date of 2024-09-23 or later.
- Don't use the edge runtime: remove any `export const runtime = "edge"`, because the adapter doesn't support it. Node middleware (Next.js 15.2+) isn't supported yet either; use standard middleware.
- On Windows, develop inside WSL: OpenNext says Windows itself works only at your own risk.
- Add one env module that validates every variable at startup (zod), and commit a .env.example with names and no values.
npm create cloudflare@latest -- my-app --framework=next --platform=workers npm run previewsrc/env.tsimport { z } from "zod"; export const env = z .object({ DATABASE_URL: z.string().url(), BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.string().url(), RESEND_API_KEY: z.string().startsWith("re_"), STRIPE_SECRET_KEY: z.string().startsWith("sk_").optional(), STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_").optional(), }) .parse(process.env);Done when: `npm run preview` serves the app locally on the Workers runtime, and it fails fast with a clear message when a variable is missing.
Docs: opennext.js.org
Set up Postgres with one owner per row
Every row a person creates carries their user id, and every query filters by the signed-in user on the server.
- Create a Neon project with a main branch for production and a dev branch for local work; use the pooled connection string.
- Connect from the app with the Neon serverless driver, which works from Workers.
- Add user_id to every table that holds a person's data, with an index and a foreign key to the users table.
- Put all reads and writes behind one data-access layer that takes the session's user id, never an id sent from the browser.
- Use a migration tool (Drizzle Kit or plain SQL files) and run migrations in CI, not by hand.
npm i @neondatabase/serverless drizzle-orm npm i -D drizzle-kitDone when: A test signed in as user A gets nothing back when it asks for user B's rows by id.
Docs: neon.com
Add sign-in with Better Auth
Email and password with verification, plus one social login (Google) is what most consumer apps need on day one.
- Install Better Auth, point it at your Postgres with its Drizzle adapter, and generate its tables with the CLI.
- Mount its handler on one Next.js route (app/api/auth/[...all]/route.ts) and read the session on the server with its API.
- Turn on email verification and password reset, sending through Resend.
- Add Google sign-in; it removes the biggest drop-off at sign-up.
- Rate-limit sign-in, sign-up and password-reset routes (Better Auth has built-in rate limiting; check it is on in production).
npm i better-auth npx @better-auth/cli@latest generateDone when: A new user can sign up, verify their email, sign out and sign back in, and a wrong password five times in a row gets rate-limited.
Docs: better-auth.com
Add the paid plan (skip if it's free)
Stripe holds the subscription; your database mirrors it from webhooks, and features are gated on the server.
- Create one Product with a monthly and a yearly Price in Stripe test mode.
- Create one Stripe Customer per user and store stripe_customer_id on the user row.
- Upgrade with a Checkout Session in subscription mode, with the user id in metadata.
- Link to the Stripe customer portal for cancelling, plan changes and card updates; make cancelling as easy as signing up.
- Handle webhooks in one route: verify the signature on the raw body, skip event ids you've already handled, then update the user's plan.
- Gate paid features on the mirrored plan on the server, never on a client flag or the Checkout redirect.
npm i stripe stripe listen --forward-to localhost:8787/api/stripe/webhookDone when: In test mode, a checkout unlocks the paid features, a cancel in the portal removes them at period end, and replaying the same webhook changes nothing.
Docs: docs.stripe.com, docs.stripe.com
Send account email from your domain
Sign-in email that lands in spam is a sign-up you lose.
- Verify a sending subdomain (for example mail.yourdomain.com) in Resend and add its SPF and DKIM records.
- Add a DMARC record for the root domain, starting at p=none with reporting.
- Write templates for verify email, magic link or password reset, and welcome.
- Let Stripe send receipts; don't duplicate them.
- Watch the free plan's 100 emails a day: a launch day of sign-ups passes it, so move to Pro before you announce.
npm i resend @react-email/componentsDone when: A verification email arrives in the inbox (not spam) for a Gmail and an Outlook test account.
Docs: resend.com
Keep secrets out of git
One copy of each secret per environment, synced to the host.
- Keep development, preview and production values in a secrets manager (Infisical) and sync production to the Worker's secrets.
- Use Stripe test keys everywhere except production.
- Add a pre-commit secret scan (gitleaks) and rotate any key that was ever committed.
npx wrangler secret put BETTER_AUTH_SECRETDone when: A fresh clone runs with secrets pulled from the secrets manager, and git history has no .env file.
Add monitoring and analytics
Know when sign-up breaks, and which free users come back and upgrade.
- Install Sentry for Next.js and upload source maps in production builds.
- Tag Sentry events with the user id, never the email address.
- Add an uptime check on a /api/health route.
- Add PostHog with a sign-up funnel and a week-two retention chart; ask for cookie consent first for EU visitors, or use cookieless mode.
npx @sentry/wizard@latest -i nextjs npm i posthog-jsDone when: A thrown test error shows up in Sentry with a readable stack trace and the user id, and no email address.
Docs: docs.sentry.io
Let people leave with their data
Consumers ask to export or delete their account; privacy laws in the EU, UK and California give them the right to.
- Add a settings page with Export my data (a JSON or CSV download) and Delete my account.
- Delete or anonymize every row with that user id, their uploads in storage, and the Stripe customer (after cancelling any subscription).
- Publish a privacy policy that lists every vendor that gets personal data (this stack: Cloudflare, Neon, Resend, Stripe, Sentry, PostHog).
Done when: Deleting a test account leaves no rows, files or Stripe customer behind for that user.
Deploy to production
Move to Workers Paid before launch and deploy from main.
- Switch the account to Workers Paid ($5/month): Free stops at 100,000 requests a day and allows 10 ms of CPU per request, which a server-rendered page can pass.
- Watch the Worker size in the deploy output: the gzip size must stay under 10 MiB on Paid (3 MiB on Free).
- Add your domain to the Worker as a custom domain in the Cloudflare dashboard.
- Deploy from CI on every push to main, with a Neon branch per pull request for previews.
npm run deployDone when: The production domain serves the app over HTTPS, sign-up works end to end, and a deploy from main goes out without a manual step.
Best practices
- Filter every query by the signed-in user's id on the server; never trust an id from the browser.
- Treat Stripe as the source of truth for the paid plan and mirror it from webhooks.
- Make cancelling a subscription as easy as starting one, with the Stripe customer portal one click from settings.
- Offer Google sign-in next to email: fewer people quit at the sign-up form.
- Build export and delete-my-account before launch, not after the first request.
- Keep personal data out of logs and error reports; tag errors with the user id only.
Pitfalls
- Unlocking the paid plan on the Checkout redirect instead of on the webhook; redirects can be skipped or faked.
- Launching on Workers Free and hitting the 100,000-requests-a-day cap (error 1027) on the day you get shared.
- Using `runtime = "edge"` or Node middleware, which the OpenNext adapter doesn't support yet.
- Sending sign-in email from the free Resend plan on launch day and stopping at 100 a day.
- Loading analytics cookies for EU visitors before they agree.
Costs and obligations
- At 1,000 users: about $5/month for Workers Paid; Neon, Better Auth, Sentry, PostHog and R2 stay on free plans, and Resend Free holds while sign-ups stay under 100 a day.
- If 100 of them pay $5 a month: Stripe takes 2.9% + 30¢ per charge ($44.50) plus 0.7% for Billing ($3.50), about $48 of the $500.
- If the app collects personal information from children under 13 in the US, COPPA applies (parental consent first); most consumer apps set a minimum age of 13 or 16 at sign-up instead.
- EU and UK users have the right to see, export and delete their data, and you need a data processing agreement with each vendor that handles it.
- Several US states, California first, require that a subscription bought online can be cancelled online. The Stripe customer portal covers this.
- Keeping card entry on Stripe Checkout keeps your PCI DSS scope small; it doesn't remove it.
- Health, banking or other sensitive data changes the picks: describe it to recommend_stack, which checks BAAs and EU regions for each vendor.
These point to obligations to check; they aren't legal or tax advice.