ShipVeryFastShipVeryFast
Documentation

Troubleshooting

Fixes for the errors you're most likely to hit on a fresh clone of ShipVeryFast, startup crashes, auth fetch errors, 403s, rate limits, CSRF failures, and flaky builds. Each section names the exact file and identifier so you can trace the cause yourself.

Most first-run failures trace back to a single cause: required environment variables are still set to the placeholders shipped in .env.example. Start by reading the error message from libs/config.ts before changing anything else.

App throws at startup: a required env var failed validation

ShipVeryFast validates its environment at import time. libs/config.ts defines a Zod envSchema and runs envSchema.parse(process.env), if any required variable is missing or malformed, the parse throws and the whole app refuses to boot. This is intentional: it fails loud and early instead of crashing deep inside a request.

The thrown ZodError lists the exact path that failed. Read it top to bottom, each entry names the variable and what was expected (for example SUPABASE_URL must be a valid URL, MAILGUN_FROM_EMAIL must be an email).

# Typical startup failure when a required var is unset
ZodError: [
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "undefined",
    "path": [ "STRIPE_SECRET_KEY" ],
    "message": "Required"
  }
]

Almost every key in the schema is required. The only optional ones are STRIPE_PRICE_ENTERPRISE, ANTHROPIC_API_KEY, and OPENAI_API_KEY (declared with .optional()). Copy .env.example to .env.local and fill in real values:

cp .env.example .env.local
# then fill in Supabase, Stripe, Mailgun and NextAuth values

Note that CSRF_SECRET and ADMIN_EMAILS appear in .env.example but are not part of the Zod schema, so a missing value there will not throw at startup, it falls back to a default (CSRF) or fails closed (admin). See the relevant sections below.

CLIENT_FETCH_ERROR / EMAIL_REQUIRES_ADAPTER_ERROR

If the app boots but every call to /api/auth/session returns 500 and the browser console shows CLIENT_FETCH_ERROR, your Supabase keys are still placeholders. The placeholder values in .env.example (like your_supabase_service_role_key and https://example.supabase.co) do not produce a usable database adapter.

NextAuth's EmailProvider hard-requires an adapter, so without a guard a fresh clone would throw EMAIL_REQUIRES_ADAPTER_ERROR on every session call. libs/auth.ts guards against this with a hasSupabaseBackend check:

const hasSupabaseBackend =
  !!env.SUPABASE_SERVICE_ROLE_KEY &&
  !env.SUPABASE_SERVICE_ROLE_KEY.startsWith("your_") &&
  env.SUPABASE_URL.startsWith("https://") &&
  !env.SUPABASE_URL.includes("example");

Forcing the Google-only auth fallback

When hasSupabaseBackend is false, the app runs adapter-less with Google OAuth and JWT sessions only, so it boots cleanly out of the box. Magic-link email sign-in is skipped because it needs the adapter to persist verification tokens. To use this fallback intentionally, leave the Supabase keys as placeholders and just provide real Google OAuth credentials:

# .env.local, Google-only fallback (no Supabase backend)
SUPABASE_URL=https://example.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
GOOGLE_CLIENT_ID=<real-google-client-id>
GOOGLE_CLIENT_SECRET=<real-google-client-secret>

To get the full experience, magic links plus database-backed sessions, add real SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY values from your Supabase project. See Authentication for the full flow.

403 from API routes: missing session or not on the admin allowlist

Admin-only API routes (for example app/api/security/audit/route.ts and app/api/ai/chat/route.ts) resolve the session with getServerSession(authOptions) and then call isAdmin(session). When that check fails they respond with { error: 'Unauthorized' } and HTTP 403:

const session = await getServerSession(authOptions);
if (!isAdmin(session)) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}

There are two distinct causes. If there is no session at all, sign in first, an unauthenticated request never passes the gate. If you are signed in but still get 403, your email is not on the admin allowlist.

libs/admin.ts authorizes against the ADMIN_EMAILS environment variable (comma-separated). It fails closed: if ADMIN_EMAILS is unset or empty, no user is treated as admin. Add your email to grant access:

# .env.local
ADMIN_EMAILS=you@example.com,teammate@example.com

Matching is case-insensitive (both the allowlist and the session email are lowercased), so casing in your email will not lock you out. See Admin dashboard for more.

429 Too many requests: a rate limiter tripped

A 429 with the body Too many requests, please try again later and a Retry-After: 60 header comes from middleware.ts, which routes each request to one of the in-memory limiters defined in libs/rateLimiter.ts. The bucket depends on the path:

LimiterApplies toBudget
authRateLimiter/api/auth/*10 / 60s
sensitiveOpRateLimiter/api/user/password, /api/user/email, /api/subscription5 / 60s
apiRateLimiterall other /api/*60 / 60s
aiRateLimiter/api/ai/chat (keyed per user email)20 / 60s
magicLinkRateLimitermagic-link email sendsRATE_LIMIT_MAGICLINK_MAX / RATE_LIMIT_MAGICLINK_DURATION

Limits are keyed by client IP (from x-forwarded-for) for the middleware limiters, and by session email for the AI limiter. The stores are RateLimiterMemory instances, so they reset when the dev server restarts. If you are hammering an endpoint during local testing, either wait out the 60-second window or restart npm run dev. See Rate limiting for the full budget breakdown.

CSRF validation failures

For every non-GET request to /api/* (POST, PUT, PATCH, DELETE), middleware.ts enforces two checks before the handler runs, and a failure on either returns 403.

First, the request origin header must exactly equal env.NEXTAUTH_URL; a mismatch responds with { error: 'Invalid origin' }. So if you see Invalid origin, make sure NEXTAUTH_URL matches the URL you are actually browsing from (for example http://localhost:3000 in development).

Second, the request must carry a valid x-csrf-token header. A missing or invalid token returns { error: 'Missing CSRF token' } or { error: 'Invalid CSRF token' }. Tokens are signed in libs/csrf.ts using CSRF_SECRET (it falls back to default-csrf-secret-change-in-production when unset) and expire after one hour. Rotating the secret immediately invalidates every previously issued token.

On the client, attach the token with the useCSRF hook from libs/useCSRF.ts, which fetches it from GET /api/csrf-token and auto-refreshes it before expiry:

import { useCSRF } from '@/libs/useCSRF';

const { getAuthHeaders } = useCSRF();

await fetch('/api/subscription', {
  method: 'POST',
  headers: getAuthHeaders(), // adds Content-Type + X-CSRF-Token
  credentials: 'same-origin',
  body: JSON.stringify(payload),
});

For one-off calls outside React, libs/useCSRF.ts also exports fetchWithCSRF(url, options), which fetches a fresh token and attaches it automatically. See CSRF protection for details.

Stripe webhooks not firing locally

The webhook handler at app/api/webhooks/stripe/route.ts verifies every incoming event with stripe.webhooks.constructEvent(payload, signature, env.STRIPE_WEBHOOK_SECRET). If the signature does not match the configured secret it returns 400 Webhook Error and the event is dropped, so subscription status never updates.

Locally, Stripe cannot reach localhost on its own. Use the Stripe CLI to forward events and copy the signing secret it prints into STRIPE_WEBHOOK_SECRET:

# Forward Stripe events to your local webhook route
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# The CLI prints a signing secret like whsec_...
# Put it in .env.local and restart the dev server:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx

Note the secret from stripe listen differs from the one shown in the Stripe Dashboard for a deployed endpoint, use the CLI one for local development. See Payments for the full Stripe setup.

Corrupted .next or stale build

If you see hydration mismatches, stale chunks, or "module not found" errors that do not match your source, the .next build cache is likely corrupted. The most common cause is running npm run build while npm run dev is still running, both write to .next and clobber each other. Stop the dev server before building.

To recover, delete the cache and restart:

# Stop any running dev/build process first, then:
rm -rf .next
npm run dev

Coverage / pre-commit failures

The prepare script installs Husky git hooks, and a pre-commit hook runs on every commit. Jest enforces coverage thresholds from jest.config.js:

coverageThreshold: {
  global: {
    branches: 10,
    functions: 5,
    lines: 10,
    statements: 10
  }
}

These thresholds act as a ratchet, they are set to the current real coverage level so they can only go up. If a commit drops coverage below them, the fix is not to lower the numbers. When code genuinely has no logic to unit-test (presentational components, thin SDK adapters, demo data), exclude it in the collectCoverageFrom array in jest.config.js rather than weakening the threshold. Existing exclusions already cover patterns like !components/landing/**, !libs/ai/**, and !app/api/ai/**, follow the same pattern.

Run coverage locally to see exactly which files pull the numbers down before committing:

npm run test:coverage
npm run coverage:open   # opens coverage/lcov-report/index.html

See Unit testing for how coverage is configured and where tests live.

Next steps