- 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.
| Tool | Required condition | Used for |
|---|---|---|
.NET 10 SDK (dotnet) | dotnet --version prints 10.x | Building and running the backend |
| Node 22 and npm | node --version prints 22 or later | The bootstrap and the Next.js frontend |
| PostgreSQL | A Docker container named postgres_db, or psql and createdb on PATH | Account data and the isolation tests |
| openssl | openssl version succeeds | Generating 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_KEYandSAAS_CSHARP_RESEND_FROMfor 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_PASSWORDandSAAS_CSHARP_SEED_USER_PASSWORD, set inbackend/.envor 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
| Method | Path | Success | Errors |
|---|---|---|---|
| GET | /health | 200 {"status":"ok"} | 403 (origin) |
| GET | /v1/me | 200 account JSON | 401 unauthorized |
| POST | /v1/login | 200 {"token","expires_at"} | 400 invalid_request, 401 invalid_credentials, 429 rate_limited |
| POST | /v1/logout | 204 | 401 unauthorized |
| POST | /v1/register | 202 {"status":"accepted"} | 400 invalid_request, 429 rate_limited |
| POST | /v1/verify | 200 {"status":"verified"} | 400 invalid_token, 400 invalid_request, 429 rate_limited |
| POST | /v1/verification/resend | 202 {"status":"accepted"} | 400 invalid_request, 429 rate_limited |
| POST | /v1/password/reset/request | 202 {"status":"accepted"} | 400 invalid_request, 429 rate_limited |
| POST | /v1/password/reset | 200 {"status":"reset"} | 400 invalid_request, 400 invalid_token, 429 rate_limited |
| POST | /v1/billing/webhook | 200 (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
| Variable | Meaning |
|---|---|
SAAS_CSHARP_DATABASE_URL | PostgreSQL URL; non-local requires a password and TLS |
SAAS_CSHARP_TEST_DATABASE_URL | Isolation-test database; required by dotnet test |
SAAS_CSHARP_HOST / SAAS_CSHARP_PORT | Backend bind address and port (defaults 127.0.0.1:3001) |
SAAS_CSHARP_API_SECRET | Trusted-frontend proof; at least 32 bytes |
SAAS_CSHARP_FRONTEND_URL | Exact frontend origin for CORS, origin rule, and email links |
SAAS_CSHARP_LOG_LEVEL | trace, debug, info, warn, error |
SAAS_CSHARP_LOG_FORMAT | pretty or json |
SAAS_CSHARP_RESEND_API_KEY / SAAS_CSHARP_RESEND_FROM | Email delivery (key at least 20 bytes, mailbox sender) |
SAAS_CSHARP_BILLING_WEBHOOK_SECRET | HMAC secret for provider webhooks; at least 32 bytes |
SAAS_CSHARP_SEED_DEV_PASSWORD / SAAS_CSHARP_SEED_USER_PASSWORD | Optional 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.