Raxx · internal docs

internal · gated

ADR 0105 — Addendum: Phase 0 Next.js Scaffold Spec

Status: Accepted Date: 2026-05-27 UTC Deciders: Kristerpher (operator); software-architect Scope: frontend/raxx-next/ — new directory, coexists with frontend/trademaster_ui/ during migration Parent ADR: docs/architecture/adr/0105-antlers-rewrite-framework-eval.md Parent issue: #2841


Context

ADR-0105 chose Next.js 14+ App Router with TypeScript as the Antlers rewrite target. The operator locked the decision on 2026-05-27 UTC. This addendum specifies Phase 0 of a four-phase migration plan:

Phase Scope Gate
0 (this doc) Local dev scaffold + testing ramps feature-developer can iterate locally
1 CI/CD pipelines Green on every PR
2 Staging deploy + validation Auth E2E passes on staging URL
3 Production cutover CF Pages production alias swapped

Phase 0 does NOT port any pages. It does NOT wire CI gates. It does NOT deploy anything. Its entire value is: a feature-developer can git clone, run npm install, run npm run dev, and have a working Next.js app on port 3000 with hot reload, an API proxy, the CE token system, the feature flag client, and a test harness ready to go.


Invariants (restated for this phase)


Goals + non-goals

Goals (Phase 0)

Non-goals (Phase 0)


Package manager: npm over pnpm

The operator asked for a recommendation. This spec chooses npm (not pnpm) for Phase 0.

Reasoning:


Node version

CRA app at frontend/trademaster_ui/ has no .nvmrc and no engines field in package.json. The system node is v22.2.0 (confirmed from environment).

Phase 0 pins Node 22 LTS (22.2.0) in frontend/raxx-next/.nvmrc:

22.2.0

Next.js 14 supports Node 18+. Node 22 LTS is the current LTS line and is explicitly supported. This matches what the developer's machine is already running, minimizing environment drift.


Folder structure

frontend/raxx-next/
├── .nvmrc                        # 22.2.0
├── .env.local.example            # template — never commit real values
├── .gitignore                    # .env.local, .next/, node_modules/
├── next.config.ts                # dev proxy + Next.js config
├── tsconfig.json                 # strict, paths aliases
├── package.json
├── MIGRATION.md                  # phase-by-phase progress checklist
│
├── app/
│   ├── layout.tsx                # root layout — imports globals.css, Sentry init
│   ├── (public)/
│   │   ├── layout.tsx            # public shell (no auth check)
│   │   ├── page.tsx              # / — stub (marketing landing)
│   │   ├── login/
│   │   │   └── page.tsx          # stub
│   │   └── signup/
│   │       └── page.tsx          # stub
│   ├── (authed)/
│   │   ├── layout.tsx            # reads session → redirects to /login if absent
│   │   └── setup/
│   │       └── page.tsx          # sample: setup-incomplete guard pattern
│   └── not-found.tsx             # global 404
│
├── components/
│   └── Button/
│       ├── Button.tsx            # sample CE-token component
│       └── Button.test.tsx       # sample Vitest + @testing-library test
│
├── lib/
│   ├── featureFlags.ts           # port of featureFlags.js with server variant
│   ├── featureFlags.server.ts    # server-only flag reader (process.env only)
│   ├── apiClient.ts              # typed fetch wrapper, credentials: 'include'
│   └── session.ts                # typed session cookie helper (server-side)
│
├── middleware.ts                 # Next.js edge middleware — route guard
│
├── styles/
│   ├── brand.css                 # verbatim copy from trademaster_ui
│   └── globals.css               # imports brand.css + Next.js base resets
│
└── tests/
    └── e2e/
        ├── playwright.config.ts  # Playwright config — port 3000
        └── smoke.spec.ts         # basic: / loads, /dashboard redirects to /login

Step-by-step setup commands

These commands are for feature-developer to execute. Do not run them during design.

# 1. Navigate to the frontend directory
cd frontend/raxx-next

# 2. Install dependencies (after package.json is created per spec)
npm install

# 3. Copy env template and populate with local values
cp .env.local.example .env.local
# Edit .env.local:
#   NEXT_PUBLIC_API_URL=http://localhost:5001   (or https://api.raxx.app for remote)
#   NEXT_PUBLIC_SENTRY_DSN=                     (leave blank in Phase 0)
#   NEXT_PUBLIC_FLAGS=                          (comma-separated flag names, optional)

# 4. Start the dev server (port 3000)
npm run dev
# CRA (if running) starts on port 3001 — both can run simultaneously

# 5. Run unit tests
npm test

# 6. Run unit tests in watch mode
npm run test:watch

# 7. Run Playwright E2E (requires dev server running in another terminal)
npm run test:e2e

# 8. Run Playwright with headed browser (for debugging)
npm run test:e2e:headed

package.json scripts (required)

{
  "scripts": {
    "dev": "next dev --port 3000",
    "build": "next build",
    "start": "next start",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "playwright test",
    "test:e2e:headed": "playwright test --headed",
    "lint": "next lint",
    "typecheck": "tsc --noEmit"
  }
}

TypeScript configuration

tsconfig.json must be strict. The following settings are required:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": false,
    "skipLibCheck": true,
    "strict": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "paths": {
      "@/components/*": ["./components/*"],
      "@/lib/*": ["./lib/*"],
      "@/styles/*": ["./styles/*"]
    },
    "plugins": [{ "name": "next" }]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

@ts-ignore and @ts-expect-error are permitted only with an inline comment explaining why. No bare suppressions. Feature-developer enforces this by convention in Phase 0; an ESLint rule (@typescript-eslint/ban-ts-comment) is added in Phase 1.


API proxy in next.config.ts

The dev server proxies /api/* to the local Raptor instance (port 5001) or the remote API, controlled by NEXT_PUBLIC_API_URL. In Next.js, this is done via rewrites:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async rewrites() {
    const apiBase = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5001'
    return [
      {
        source: '/api/:path*',
        destination: `${apiBase}/api/:path*`,
      },
    ]
  },
}

export default nextConfig

Note on the CRA proxy difference: CRA's setupProxy.js used http-proxy-middleware in the webpack dev server. Next.js rewrites() are the equivalent: they apply in npm run dev and also in the production build. In production (Phase 2/3), the rewrite target changes from localhost to the live API URL via env var. No code change needed between dev and prod — only the env var changes.

REACT_APP_*NEXT_PUBLIC_* migration convention:

Old (CRA) New (Next.js) Notes
REACT_APP_API_URL NEXT_PUBLIC_API_URL Public; safe in JS bundle
REACT_APP_SENTRY_DSN NEXT_PUBLIC_SENTRY_DSN Public; safe in JS bundle
REACT_APP_FLAGS NEXT_PUBLIC_FLAGS Public; safe in JS bundle
REACT_APP_* (any others) NEXT_PUBLIC_* Rename prefix, same semantics

Server-side-only env vars (no NEXT_PUBLIC_ prefix) are never exposed to the browser. Use this for any env var that must not appear in the JS bundle (internal service tokens, signing secrets). None exist in Phase 0 — this is forward documentation.

.env.local is in .gitignore. Feature-developer populates it locally. .env.local.example ships in the repo with placeholder values only.


CE design token convention

styles/brand.css is copied verbatim from frontend/trademaster_ui/src/styles/brand.css. No edits during the copy.

Single source of truth: frontend/trademaster_ui/src/styles/brand.css remains authoritative until Phase 3 cutover, at which point the CRA copy is the one removed. During Phase 0–2, if brand tokens change they must be updated in both files. This is intentional — it prevents an invisible dependency between the two apps during migration.

styles/globals.css imports brand.css and sets Next.js base resets:

/* globals.css */
@import './brand.css';

*, *::before, *::after {
  box-sizing: border-box;
}

html {
  font-family: var(--raxx-font-sans);
  background-color: var(--raxx-bg);
  color: var(--raxx-fg);
}

body {
  margin: 0;
}

Convention — enforced by code review, not by a linter in Phase 0:

Any component file that hardcodes a hex color, a font-family string, or a pixel value that matches a token value is a review blocker. Use var(--raxx-*) instead.

A Phase 1 sub-card should add a Stylelint rule (declaration-property-value-disallowed-list) to enforce this mechanically.


Feature flag client

lib/featureFlags.ts ports the CRA featureFlags.js logic to TypeScript.

// lib/featureFlags.ts
// Client-side flag reader — import in Client Components only.
// For Server Components, use lib/featureFlags.server.ts instead.

export type FlagSet = Record<string, boolean>

function buildFlagSet(): FlagSet {
  // Priority 1: runtime injection (server sets window.__FLAGS__)
  if (typeof window !== 'undefined' && window.__FLAGS__ &&
      typeof window.__FLAGS__ === 'object') {
    return window.__FLAGS__ as FlagSet
  }

  // Priority 2: build-time env var, comma-separated enabled flag names
  const envFlags = process.env.NEXT_PUBLIC_FLAGS ?? ''
  if (envFlags) {
    const enabled: FlagSet = {}
    envFlags.split(',').forEach((name) => {
      const trimmed = name.trim()
      if (trimmed) enabled[trimmed] = true
    })
    return enabled
  }

  return {}
}

// Evaluated once at module load (Client Component hydration time).
// Do NOT call this in Server Components — use featureFlags.server.ts.
const _flags: FlagSet = buildFlagSet()

export function isEnabled(flagName: string): boolean {
  return Boolean(_flags[flagName])
}

export default { isEnabled }

lib/featureFlags.server.ts is a server-only variant for React Server Components and middleware.ts. It reads from process.env directly (no window):

// lib/featureFlags.server.ts
// Server-only. Never import from a Client Component ('use client' file).
// Next.js will error if a server-only import reaches the client bundle.
import 'server-only'

export function isEnabledServer(flagName: string): boolean {
  const envFlags = process.env.NEXT_PUBLIC_FLAGS ?? ''
  if (!envFlags) return false
  return envFlags.split(',').some((name) => name.trim() === flagName)
}

The 'server-only' import is a Next.js convention that causes a build error if the module is accidentally imported in a Client Component. This is the mechanical guard against the "client-side flag reads at module scope" anti-pattern identified in ADR-0105.

Global type augmentation — add to a types/globals.d.ts file:

// types/globals.d.ts
interface Window {
  __FLAGS__?: Record<string, boolean>
}

Route guard pattern (primary architectural deliverable)

This is the fix for the route-guard race condition that motivated the rewrite. The pattern appears in two places: middleware.ts (edge) and app/(authed)/layout.tsx (layout-level).

middleware.ts (edge middleware — authoritative)

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'

// Routes that require an authenticated session.
// All paths under (authed) must be listed here OR matched by prefix.
const AUTHED_PREFIXES = ['/dashboard', '/backtest', '/trading', '/settings', '/setup']

// Routes that should redirect authenticated users away (e.g., /login → /dashboard).
const AUTH_ONLY_ROUTES = ['/login', '/signup']

export async function middleware(request: NextRequest): Promise<NextResponse> {
  const { pathname } = request.nextUrl

  // Read the session cookie set by Raptor after WebAuthn ceremony.
  // Cookie name must match Raptor's Set-Cookie name — currently 'session'.
  // Domain: .raxx.app — present on requests to raxx.app and subdomains.
  const sessionCookie = request.cookies.get('session')?.value

  const isAuthedRoute = AUTHED_PREFIXES.some((p) => pathname.startsWith(p))
  const isAuthOnlyRoute = AUTH_ONLY_ROUTES.some((p) => pathname.startsWith(p))

  // Authed route without session cookie: redirect to /login.
  // NOTE: In Phase 0, this is cookie-presence-only. In Phase 1+, call
  // GET /api/auth/session to validate the cookie server-side before redirecting.
  // Cookie presence is sufficient for Phase 0 (dev scaffold, no real auth traffic).
  if (isAuthedRoute && !sessionCookie) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('next', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // Auth-only route with valid session: redirect to /dashboard.
  if (isAuthOnlyRoute && sessionCookie) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  return NextResponse.next()
}

export const config = {
  // Apply middleware to all routes except static assets and Next.js internals.
  matcher: ['/((?!_next/static|_next/image|favicon.ico|icons/).*)'],
}

Why this eliminates the race: The middleware runs at the edge before any React renders. There is no useState, no useEffect, no localStorage read, no context provider initialization timing. The session cookie is either present or not at request time. The client never sees a flash of the wrong page.

Phase 1 upgrade path: Replace the cookie-presence check with a server-side GET /api/auth/session call. The interface is:

// Upgrade from Phase 0 cookie-presence check to server-side validation:
const sessionRes = await fetch(
  `${process.env.NEXT_PUBLIC_API_URL}/api/auth/session`,
  {
    headers: {
      Cookie: `session=${sessionCookie}`,
    },
    cache: 'no-store',
  }
)
if (!sessionRes.ok) {
  // session is expired or invalid — redirect same as absent
}

This is intentionally not in Phase 0. The scaffold uses cookie-presence only to avoid a hard dependency on Raptor being available during dev.

app/(authed)/layout.tsx (layout-level guard — belt + suspenders)

// app/(authed)/layout.tsx
// This layout is a React Server Component.
// It provides a secondary guard: even if middleware is misconfigured,
// the layout re-validates the session before rendering authed content.
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

export default async function AuthedLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const cookieStore = await cookies()
  const sessionCookie = cookieStore.get('session')

  if (!sessionCookie) {
    // Middleware should have caught this. If we reach here, middleware
    // was bypassed or misconfigured. Redirect defensively.
    redirect('/login')
  }

  // In Phase 1+: call GET /api/auth/session here to validate the cookie
  // and load user context (name, subscription tier, setup status).

  return <>{children}</>
}

app/(authed)/setup/page.tsx (sample — setup-incomplete guard)

// app/(authed)/setup/page.tsx
// Demonstrates the "setup incomplete" redirect pattern.
// In Phase 1+, this reads setup status from the session response.
// In Phase 0, it is a stub showing the intended shape.
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

export default async function SetupPage() {
  const cookieStore = await cookies()
  const sessionCookie = cookieStore.get('session')

  // Defensive: redirect to login if somehow reached without a session.
  if (!sessionCookie) {
    redirect('/login')
  }

  // Phase 1+ TODO: parse session payload to read onboardingCompleted.
  // const session = await getSession(sessionCookie.value)
  // if (session.onboardingCompleted) redirect('/dashboard')

  return (
    <main>
      <h1>Complete your setup</h1>
      <p>Setup wizard will live here.</p>
    </main>
  )
}

Contrast with CRA pattern: In CRA, RouteGuard.js reads onboardingCompleted from SettingsContext which reads from localStorage. By the time RouteGuard renders, React may not yet have re-rendered with the updated context value — causing the bounce to /setup even though onboarding is done. The Next.js RSC pattern above reads the authoritative value from the server before a single byte is rendered. No race possible.

Cookie invariants:

// These must match Raptor's Set-Cookie header for the session cookie.
// If Raptor changes cookie settings, middleware.ts must be updated to match.
//
// Domain:   .raxx.app     (accessible to raxx.app + any subdomain)
// SameSite: None           (required for cross-origin requests from raxx.app → api.raxx.app)
// Secure:   true           (HTTPS only — enforced in staging + prod; dev uses http localhost)
// HttpOnly: true           (not readable by JavaScript — Raptor sets this)
//
// In Next.js fetch calls from Client Components:
//   fetch('/api/...', { credentials: 'include' })  // includes the session cookie
//
// credentials: 'include' is the fetch equivalent of axios.defaults.withCredentials = true

Sample CE component: Button

// components/Button/Button.tsx
// Demonstrates the CE token convention in TSX.
// Tokens come from CSS custom properties defined in styles/brand.css.
// No hex values, no hardcoded fonts, no magic numbers.

interface ButtonProps {
  children: React.ReactNode
  variant?: 'primary' | 'ghost'
  onClick?: () => void
  disabled?: boolean
  type?: 'button' | 'submit' | 'reset'
}

export function Button({
  children,
  variant = 'primary',
  onClick,
  disabled = false,
  type = 'button',
}: ButtonProps) {
  return (
    <button
      type={type}
      onClick={onClick}
      disabled={disabled}
      style={{
        // All values are CSS custom properties from brand.css.
        // Never write a hex value or pixel value that matches a token here.
        backgroundColor: variant === 'primary'
          ? 'var(--raxx-moss)'
          : 'transparent',
        color: 'var(--raxx-fg)',
        border: variant === 'ghost'
          ? '1px solid var(--raxx-n-700)'
          : 'none',
        borderRadius: 'var(--raxx-radius-sm)',
        padding: 'var(--raxx-space-2) var(--raxx-space-4)',
        fontFamily: 'var(--raxx-font-sans)',
        cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.5 : 1,
      }}
    >
      {children}
    </button>
  )
}
// components/Button/Button.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Button } from './Button'

describe('Button', () => {
  it('renders children', () => {
    render(<Button>Submit</Button>)
    expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
  })

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Submit</Button>)
    expect(screen.getByRole('button')).toBeDisabled()
  })
})

Testing setup

Vitest (unit + component)

Install: vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom jsdom

vitest.config.ts:

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      // No threshold enforced in Phase 0.
      // Add thresholds in Phase 1 once baseline is established.
    },
  },
})

tests/setup.ts:

import '@testing-library/jest-dom'

Playwright (E2E)

Playwright is already used in this repo for live verification (per memory). Reuse the same installation.

tests/e2e/playwright.config.ts:

import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  // Phase 0: dev server must be started manually before running E2E.
  // Phase 1: add webServer config to start Next.js automatically.
})

tests/e2e/smoke.spec.ts:

import { test, expect } from '@playwright/test'

test('public landing loads', async ({ page }) => {
  await page.goto('/')
  await expect(page).not.toHaveTitle(/error/i)
})

test('unauthenticated /dashboard redirects to /login', async ({ page }) => {
  await page.goto('/dashboard')
  // Middleware redirects to /login without rendering /dashboard content.
  await expect(page).toHaveURL(/\/login/)
})

CI stub (Phase 0 — not a required check)

.github/workflows/antlers-next-ci.yml is created but not added to branch protection rules. This is Phase 1 work. The file's existence makes the CI wiring visible without blocking anything.

# .github/workflows/antlers-next-ci.yml
# Phase 0: stub. Not a required check. Wire as required check in Phase 1.
name: Antlers Next.js CI

on:
  push:
    paths:
      - 'frontend/raxx-next/**'
  pull_request:
    paths:
      - 'frontend/raxx-next/**'

jobs:
  test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: frontend/raxx-next
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: frontend/raxx-next/.nvmrc
          cache: 'npm'
          cache-dependency-path: frontend/raxx-next/package-lock.json
      - run: npm ci
      - run: npm run typecheck
      - run: npm test
      # E2E skipped in Phase 0 CI — no server start configured.
      # Add webServer + E2E run in Phase 1.

.gitignore additions for raxx-next

The following must be in frontend/raxx-next/.gitignore:

.env.local
.env.*.local
.next/
node_modules/
*.tsbuildinfo
coverage/
playwright-report/
test-results/

MIGRATION.md skeleton

This file lives at frontend/raxx-next/MIGRATION.md and is the living checklist for the migration team.

# Antlers → Next.js Migration Checklist

Parent ADR: docs/architecture/adr/0105-antlers-rewrite-framework-eval.md
Phase 0 spec: docs/architecture/adr/0105-addendum-phase0-nextjs-scaffold.md
Parent issue: #2841

## Phase 0 — Scaffold (this PR)

- [ ] next.config.ts with API proxy rewrite
- [ ] tsconfig.json (strict)
- [ ] .nvmrc (22.2.0)
- [ ] .env.local.example with documented variables
- [ ] styles/brand.css (verbatim copy from trademaster_ui)
- [ ] styles/globals.css
- [ ] lib/featureFlags.ts (client)
- [ ] lib/featureFlags.server.ts (server-only)
- [ ] lib/apiClient.ts (typed fetch wrapper)
- [ ] lib/session.ts (session cookie helper)
- [ ] middleware.ts (route guard — cookie-presence, Phase 0)
- [ ] app/layout.tsx (root layout)
- [ ] app/(public)/layout.tsx
- [ ] app/(public)/page.tsx (stub)
- [ ] app/(public)/login/page.tsx (stub)
- [ ] app/(public)/signup/page.tsx (stub)
- [ ] app/(authed)/layout.tsx (server-side session check)
- [ ] app/(authed)/setup/page.tsx (sample setup guard)
- [ ] components/Button/Button.tsx (CE token sample)
- [ ] components/Button/Button.test.tsx
- [ ] tests/e2e/playwright.config.ts
- [ ] tests/e2e/smoke.spec.ts
- [ ] .github/workflows/antlers-next-ci.yml (stub, not required)
- [ ] MIGRATION.md (this file)

## Phase 1 — CI/CD (not yet started)

- [ ] Wire antlers-next-ci.yml as required check on PRs touching raxx-next/
- [ ] Add Playwright E2E with webServer auto-start
- [ ] Add ESLint + @typescript-eslint/ban-ts-comment
- [ ] Add Stylelint for CE token enforcement
- [ ] Coverage threshold: 60% on lib/ and components/
- [ ] Upgrade middleware.ts: replace cookie-presence with GET /api/auth/session call

## Phase 2 — Staging deploy (not yet started)

- [ ] Choose deployment target: CF Pages + next-on-pages adapter vs Vercel vs Heroku dyno
  (decision deferred from Phase 0 — can be made at start of Phase 2)
- [ ] Deploy to staging URL (separate from CRA staging)
- [ ] Auth E2E on staging: /signup → passkey ceremony → session cookie → /dashboard
- [ ] Verify WebAuthn on Safari + Chrome (incognito mode)
- [ ] Port SignupPage, LoginPage, PublicLanding into raxx-next

## Phase 3 — Production cutover (not yet started)

- [ ] Dashboard page ported + green on staging
- [ ] CF Pages production alias swapped to Next.js build
- [ ] CRA build kept as rollback alias for 14 days
- [ ] Sentry monitoring for 72h
- [ ] After 14-day soak: remove frontend/trademaster_ui/ (file a sub-card)

## CRA patterns that do NOT port directly

These patterns exist in trademaster_ui and must be rethought for Next.js:

1. **Top-level `useEffect` for routing decisions** (App.js lines ~80–140)
   In Next.js: routing decisions belong in `middleware.ts` or RSC `redirect()`.
   `useEffect` for routing produces the race condition this rewrite is designed to fix.

2. **Module-scope `isEnabled()` call at import time** (featureFlags.js line 35: `const _flags = buildFlagSet()`)
   In Next.js: Client Component version re-evaluates at hydration (fine).
   Server Component version uses `featureFlags.server.ts` which reads `process.env` at
   call time, not module-import time. Do not evaluate flags at module scope in RSC files.

3. **Multi-context providers stacked in App.js**
   In Next.js: data fetched in RSC and passed as props. Client contexts still work but
   should wrap only the parts of the tree that need them, not the entire app. Avoid
   six stacked providers at the root layout level.

4. **`axios.defaults.withCredentials = true` (global mutation)**
   In Next.js: use `fetch(..., { credentials: 'include' })` per-call, OR wrap in
   `lib/apiClient.ts` with credentials hardcoded. Never mutate global axios defaults —
   the server-side render context does not have the same global state as the browser.
   RSC uses plain `fetch`; Client Components use the typed apiClient.

5. **`setupProxy.js` (CRA webpack dev proxy)**
   In Next.js: `next.config.ts` rewrites() handle this. No `setupProxy.js`.

Dependency list (Phase 0)

Production:

next@14 (or latest 14.x)
react@18
react-dom@18
@simplewebauthn/browser@13  (same version as CRA — passkey ceremony unchanged)
@sentry/nextjs@8            (replaces @sentry/react; Next.js-specific instrumentation)
server-only                 (Next.js convention package for server-only modules)

Development:

typescript@5
@types/react@18
@types/react-dom@18
vitest@2
@vitejs/plugin-react@4
@testing-library/react@14
@testing-library/jest-dom@6
@testing-library/user-event@14
jsdom@24
@playwright/test@1
eslint@8
eslint-config-next@14

Explicit non-dependencies for Phase 0:


Security + GDPR checklist


Open questions (none block Phase 0; all block Phase 1+)

  1. Deployment target for Phase 2: CF Pages + @cloudflare/next-on-pages vs Vercel vs Heroku Node.js dyno. Must be decided before Phase 1 sub-cards are filed. Does not need to be decided before Phase 0 starts.

  2. Session cookie validation in middleware.ts Phase 1 upgrade: GET /api/auth/session called from Next.js middleware — does Raptor respond within the edge runtime latency budget? If Raptor is on Heroku Standard-0, cold-start latency may be 200–400ms on the first middleware call. Acceptable? Or should Phase 1 validate locally via a JWT embedded in the cookie rather than a roundtrip? Flag for architecture review at Phase 1.

  3. @cloudflare/next-on-pages compatibility audit: Before committing to CF Pages as the Phase 2 target, feature-developer should run npx @cloudflare/next-on-pages@1 on the Phase 0 scaffold and check for unsupported APIs. This is a Phase 0 exit criterion if CF Pages is the chosen target — but the audit itself is Phase 1 work. ```