Next.js SaaS
How to Build a Next.js SaaS for Production: Auth, Billing, Jobs, and Deployment
Build a production Next.js 16 SaaS with authentication, Postgres, subscriptions, background work, secure configuration, testing, and deployment.
A SaaS becomes difficult where features meet state: identity, authorization, billing, retries, migrations, and releases. The React component tree is only one part of the system.
Define the first complete customer loop
Start with one loop a customer can finish: discover the product, create an account, complete onboarding, create the primary resource, receive the result, and return later to find the same state. This exposes the real system boundaries earlier than a long feature inventory.
Write the state transitions beside the screens. Name who may perform each transition, which records change, which external calls occur, and what happens when a call is repeated or fails. A reliable SaaS is designed around those transitions rather than around a collection of dashboard cards.
- —Public acquisition and documentation routes.
- —Authentication, session, and account recovery.
- —The primary domain object and its ownership rules.
- —A billing entitlement or explicit free-plan boundary.
- —Email, job, audit, and failure evidence.
Separate public, application, and server-only code
Use route groups to give marketing and authenticated application routes different layouts without changing their URLs. Keep pages and layouts as Server Components, then add Client Components around forms and controls that need browser state. This keeps public content crawlable and reduces dashboard JavaScript.
Put database access, authorization, billing adapters, and secret-bearing integrations in server-only modules. A data access layer gives secure reads one auditable home. Server Actions own mutations initiated by the React UI; Route Handlers own webhooks, health checks, and HTTP interfaces used outside that UI.
A practical SaaS route tree
src/app/
├── (marketing)/page.tsx
├── (marketing)/pricing/page.tsx
├── (auth)/login/page.tsx
├── (app)/dashboard/page.tsx
├── (app)/projects/[id]/page.tsx
├── api/stripe/webhook/route.ts
└── api/health/route.ts
src/lib/
├── auth.ts
├── dal.ts
├── db.ts
└── billing.tsModel ownership in the database
Use a relational database for accounts, memberships, domain records, entitlements, webhook events, and jobs that require transactions and constraints. Give each owned record an account or tenant key. Enforce uniqueness and foreign keys in the database so concurrency cannot bypass assumptions made in application code.
Migrations are production code. Make additive changes first, backfill data separately when necessary, deploy code that can read the transitional shape, and remove old fields later. A release should not assume every replica and job switches schema at the same instant.
Treat billing as asynchronous state
Checkout begins a billing process; it is not the final authority on subscription state. Create the Checkout Session on the server, redirect to the provider, and update local entitlements from verified webhook events. Store provider customer and subscription IDs beside the owning account.
Process retries safely by recording event IDs under a unique constraint. Handle activation, plan changes, failed payments, cancellation, and deletion explicitly. Decide which actions require an active entitlement and how a grace period affects access. The UI should read normalized local billing state rather than call the provider for every page.
Move slow and retryable work out of requests
Email, imports, exports, provider synchronization, and report generation should not keep an HTTP request open. Write a durable job or emit a workflow event as part of the mutation, return a useful state to the user, and let a worker perform the slow work with retries and deadlines.
Make jobs idempotent, record attempts, and distinguish retryable provider failures from invalid input. The dashboard should show pending, successful, and failed states instead of pretending every background action completes immediately.
- —Stable job identity and deduplication key.
- —Bounded retries with backoff.
- —Timeouts around external calls.
- —An inspectable final error and recovery path.
Test boundaries and recovery
Unit-test domain rules, integration-test the data access layer and mutations, and use browser tests for signup, onboarding, the primary workflow, and billing. Add adversarial cases: a user requests another account's record, a webhook repeats, a database constraint races, and an external provider times out.
Run the production build separately from lint, types, and tests. Start the built app with production-like environment values, apply migrations in a controlled step, and smoke-test public content, authenticated reads, one mutation, the webhook route, and health behavior.
Deploy the whole runtime contract
Adios runs the standard Next.js production server as a persistent Node.js process, so Server Components, Route Handlers, database connections, and authenticated pages share one application release. The manifest records the build, start, port, health path, resources, and secret references beside the source.
Deploy a preview, inspect build and runtime logs, verify migrations and required services, then promote after the health route succeeds. Custom domains and managed TLS remain attached to the promoted release. If a candidate cannot start or report healthy, its evidence remains available without making it the healthy public version.
adios.yaml
name: northstar-saas
build_cmd: npm ci && npm run build
start_cmd: npm start
runtime:
name: node@24
port: 3000
health_path: /api/health
requires:
- db
secrets:
DATABASE_URL: secret://DATABASE_URL
AUTH_SECRET: secret://AUTH_SECRET
STRIPE_SECRET_KEY: secret://STRIPE_SECRET_KEY