Starter
NextJS Frontend + C# Backend SaaS Kit
Kit version
0.1.0
Documentation version
0.1.0
Covers kit version
0.1.0

NextJS and C# SaaS Kit — developer documentation

This documentation describes the released behavior of the NextJS and C# SaaS Kit: a production-ready starting point for a SaaS product, with a Next.js frontend and an ASP.NET Core (C#) backend. It is written to stand on its own. A technical reader can evaluate the kit without installing it, and a developer or operator can configure, run, secure, extend, and prepare it for public exposure using this page alone.

The kit is published by StarterFoundry as a versioned product. This documentation describes kit version 0.1.0 and is itself version 0.1.0; see Version coverage for the version relationship.

Overview

What the kit provides

  • Customer accounts. Registration, email verification, login, logout, password reset, breached-password screening, account lockout with owner alerting, and opaque revocable sessions with absolute and idle expiry.
  • A billing foundation. A provider-agnostic billing seam, a mock provider whose webhooks are signed with HMAC-SHA256, and entitlement updates driven by subscription events. It is not a payment integration.
  • An admin surface. An admin-only page that renders account state for the signed-in administrator. Capability comes from the backend, not from the page.
  • Operations. Structured logging with a JSON production format, a closed audit.* event stream, request-ID correlation, per-IP and per-flow rate limiting, exact-origin checks, a non-root backend container image, and a container health check.
  • Continuous integration. Backend formatting, isolation tests, a release build, a dependency vulnerability scan, a frontend build, and browser end-to-end tests.

How the pieces fit together

One repository holds two independently buildable trees. There is no root package manifest, no root solution file, and no monorepo orchestrator coupling them.

The backend is the account authority. It owns customer records, password hashing, sessions, verification and reset tokens, rate limiting, outbound email, the billing webhook, and the audit trail. Every call that carries account authority proves two separate things: the caller is the trusted frontend (a constant-time application-secret check), and the acting user is identified by an opaque session token the backend validates on every call. Callers cannot supply a user identity; identity comes only from the presented session.

The frontend is the browser-facing application. The browser talks only to the Next.js server; the Next.js server talks to the backend over the REST API using server-only environment values. The browser never receives the backend origin or the application secret. The frontend session cookie wraps the backend session token for convenience, but it is not an authorization authority: the backend re-validates the token independently on every call.

The bootstrap is a single Node program. It prepares local development: databases, environment files, migrations, and two seeded accounts. It is idempotent and never overwrites values that already exist.

Who this documentation is for

  • A technical buyer assessing the kit's capabilities, security posture, operational duties, extension boundaries, and limits.
  • A developer or operator starting the kit, extending it, and preparing it for public exposure on their own platform.

What the kit is not

The kit is a foundation, not a finished product. It does not include a real billing provider, multi-factor authentication, logged-in password or email change, product data models beyond the account and entitlement foundation, or a production deployment platform. The full list is under Deliberate exclusions and residual limits.

Prerequisites

Development tools

None of these is installed for you. Provide them before the first run.

ToolRequired conditionUsed for
.NET 10 SDK (dotnet)dotnet --version prints 10.xBuilding and running the backend
Node 22 and npmnode --version prints 22 or laterThe bootstrap and the Next.js frontend
PostgreSQLA Docker container named postgres_db, or psql and createdb on PATHAccount data and the isolation tests
opensslopenssl version succeedsGenerating local secrets during the bootstrap

Customer-provided inputs

The CLI applies the product identity at instantiation. After placement you supply, in backend/.env:

  • SAAS_CSHARP_RESEND_API_KEY and SAAS_CSHARP_RESEND_FROM for real email delivery. The bootstrap writes a development placeholder key; local development starts without a real one, but verification and reset mail is not delivered until you provide your own key and a verified sender.
  • Optionally SAAS_CSHARP_SEED_DEV_PASSWORD and SAAS_CSHARP_SEED_USER_PASSWORD, set in backend/.env or the shell environment before the first run, to pin the seeded account passwords. Re-running the bootstrap never resets existing accounts.

Never commit a real secret. .env files are git-ignored; the .env.example templates are committable and carry no secrets.

First run

After the StarterFoundry CLI has placed this kit, from the project root:

node scripts/bootstrap-dev.mjs
cd backend
dotnet run

cd frontend
npm ci
npm run dev

The bootstrap verifies openssl, dotnet, and PostgreSQL; creates the development and isolation-test databases; writes backend/.env and frontend/.env without overwriting existing values; applies migrations; and seeds two verified accounts. It is idempotent and safe to re-run. With every prerequisite present these commands start local development: the backend serves on http://127.0.0.1:3001 and the frontend on http://127.0.0.1:3000.

How it works

The trusted-frontend boundary

Account routes require two independent proofs. The first is the application secret, compared in constant time; the frontend holds it server-side only. The second is the acting user's opaque session token. A request with a foreign or missing Origin/frontend-origin header is rejected with 403. CORS and origin headers are never treated as authentication.

Customer accounts and sessions

Passwords are hashed with argon2id (64 MiB, 3 iterations, 1 lane) and must be at least 12 characters, at most 1024 bytes, and absent from the shipped common-password list. Breached-password screening uses the Pwned Passwords range API; if the check is unavailable it fails open and proceeds. Five consecutive failed sign-ins on a loginable account lock it for 15 minutes and send an owner alert.

Sessions are 32 random bytes, stored only as SHA-256 hashes. They expire after 12 hours absolutely and after 30 minutes of inactivity. Logout revokes the presented session; resetting a password revokes every session for that account.

One-time tokens

Email verification tokens live 24 hours; password-reset tokens live 1 hour. Both are 64 lowercase hex characters, stored hashed, and consumed atomically, so a replayed or expired token is rejected. Admin and lifetime accounts are not activated by verification tokens. Password reset only issues a link for verified accounts; unknown or unverified addresses receive the same accepted response and no email.

Billing

POST /v1/billing/webhook accepts provider events signed with HMAC-SHA256 in X-Billing-Webhook-Signature. subscription.activated sets the subscriber entitlement and next billing date; subscription.cancelled clears both; payment.failed is advisory and changes nothing. Unknown customers are a no-op success so deliveries are retried without leaking account existence.

Logging and the audit trail

Every request is logged with method, path, status, elapsed time, and the X-Request-Id correlation header. Account and billing actions emit a closed set of audit.* events with the customer UUID, never the email address. SAAS_CSHARP_LOG_FORMAT=json selects machine-readable output; the container image sets it.

Configuration

Configuration is fail-closed. The backend reads non-secret defaults plus prefixed SAAS_CSHARP_* environment variables only; backend/.env is auto-loaded for local development and real environment variables win. Unprefixed aliases are ignored. A missing or short application secret, an invalid frontend origin, a missing Resend key or sender, a short billing secret, or a non-local database URL without a password and TLS stops startup with a clear error.

Supported extension

Add a page

Add a route under frontend/src/app/. Server components fetch through the server-only client; browser code never holds the backend origin. Add or update a Vitest test under frontend/tests/, mirroring src/.

Add an endpoint

Add a handler in backend/Http/AccountEndpoints.cs (or a new module) and map it in AccountApi.Map. Reuse the trust guard for the application secret and origin rule, and validate sessions exactly as /v1/me does. Extend the isolation tests with the same positive and negative cases.

Replace the billing provider

Implement the provider calls behind the webhook seam: verify the provider's signature, parse its event into the existing event shapes, and map it to the entitlement update. Keep POST /v1/billing/webhook as the delivery path.

Brand the product

The display name, protocol headers, cookie slug, and environment prefix derive from frontend/src/lib/brand.ts and backend/Brand.cs. Structural strings must derive from those modules; do not rename identifiers to change a display name. Marketing copy lives in frontend/src/lib/landing-content.ts.

Change email delivery

backend/Mail/Mailer.cs implements IMailer against Resend. Swap in another provider by implementing the same interface; keep the anti-enumeration pad and the short network timeout so delivery latency stays uninformative.

Evolve the schema

Migrations are embedded in backend/Data/Db.cs and applied at startup with a schema_migrations ledger. Add a new named migration; never edit an applied one. The isolation tests run against a real PostgreSQL database and fail closed if SAAS_CSHARP_TEST_DATABASE_URL is unset.

Reference and limits

HTTP surface

MethodPathSuccessErrors
GET/health200 {"status":"ok"}403 (origin)
GET/v1/me200 account JSON401 unauthorized
POST/v1/login200 {"token","expires_at"}400 invalid_request, 401 invalid_credentials, 429 rate_limited
POST/v1/logout204401 unauthorized
POST/v1/register202 {"status":"accepted"}400 invalid_request, 429 rate_limited
POST/v1/verify200 {"status":"verified"}400 invalid_token, 400 invalid_request, 429 rate_limited
POST/v1/verification/resend202 {"status":"accepted"}400 invalid_request, 429 rate_limited
POST/v1/password/reset/request202 {"status":"accepted"}400 invalid_request, 429 rate_limited
POST/v1/password/reset200 {"status":"reset"}400 invalid_request, 400 invalid_token, 429 rate_limited
POST/v1/billing/webhook200 (empty)400 (empty), 401 (empty), 500 (empty)

Account JSON fields: id, email, email_verified, subscriber_active, subscription_started_at, next_billing_at, lifetime, admin.

Configuration reference

VariableMeaning
SAAS_CSHARP_DATABASE_URLPostgreSQL URL; non-local requires a password and TLS
SAAS_CSHARP_TEST_DATABASE_URLIsolation-test database; required by dotnet test
SAAS_CSHARP_HOST / SAAS_CSHARP_PORTBackend bind address and port (defaults 127.0.0.1:3001)
SAAS_CSHARP_API_SECRETTrusted-frontend proof; at least 32 bytes
SAAS_CSHARP_FRONTEND_URLExact frontend origin for CORS, origin rule, and email links
SAAS_CSHARP_LOG_LEVELtrace, debug, info, warn, error
SAAS_CSHARP_LOG_FORMATpretty or json
SAAS_CSHARP_RESEND_API_KEY / SAAS_CSHARP_RESEND_FROMEmail delivery (key at least 20 bytes, mailbox sender)
SAAS_CSHARP_BILLING_WEBHOOK_SECRETHMAC secret for provider webhooks; at least 32 bytes
SAAS_CSHARP_SEED_DEV_PASSWORD / SAAS_CSHARP_SEED_USER_PASSWORDOptional pinned seed passwords

Frontend uses SAAS_CSHARP_BACKEND_URL, SAAS_CSHARP_API_SECRET, SAAS_CSHARP_FRONTEND_URL, SAAS_CSHARP_AUTH_SECRET, and SAAS_CSHARP_AUTH_TRUST_HOST. All frontend values are server-only.

Audit events

audit.login_success, audit.login_failure, audit.account_locked, audit.register, audit.verify, audit.password_reset_requested, audit.password_reset_consumed, audit.logout, audit.billing_event, audit.password_pwned, audit.pwned_check_unavailable.

Deliberate exclusions and residual limits

  • Mock billing only; no real payment provider.
  • No multi-factor authentication, logged-in password change, or email change.
  • No direct customer API contract: the HTTP surface is internal to the frontend, plus the provider webhook.
  • Rate limiting is in-process and per-instance, not distributed.
  • Breached-password screening fails open when the range API is unavailable.
  • The Content Security Policy allows 'unsafe-inline' scripts, as required by the pinned Next.js hydration path; production hardening to nonces is a documented follow-up.
  • Landing and sample copy are kit source copy until the customer rewrites frontend/src/lib/landing-content.ts.
  • No metrics export, log shipping, or log retention; the operator owns observability.
  • TLS termination, network isolation, backups, secret storage, and database operations are operator-owned. See docs/tls-deployment-contract.md.

Version coverage

This documentation is version 0.1.0 and covers kit version 0.1.0. It describes released behavior only. It is published as a separate documentation artifact and is not installed by the CLI. A later documentation-only revision increments the documentation version without changing the covered kit version.