Adios
BlogNext.js SaaS

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.

Adios teamUpdated July 17, 202610 min read

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

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

Build authentication and authorization together

Use a maintained authentication library unless owning password hashing, provider flows, session rotation, recovery, and multifactor authentication is central to the product. Store session material in secure HTTP-only cookies and resolve the current identity on the server.

Authentication does not grant access to every record. Check authorization in the data access layer, every Server Action, and every protected Route Handler. Proxy can perform an optimistic redirect for obvious unauthenticated requests, but secure checks must remain beside the data and mutation.

export async function getProject(projectId: string) {
  const session = await verifySession();
  const membership = await getMembership(session.userId);

  return db.project.findFirst({
    where: {
      id: projectId,
      accountId: membership.accountId,
    },
  });
}

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
  All articles