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)
- No stored credentials. The session cookie that
middleware.tsreads is opaque (server-issued, short-lived, server-revocable). Nothing about the passkey private key, user password, or broker key ever touches Next.js state. - Passkeys / WebAuthn only. The
middleware.tsroute guard pattern in this doc gates all(authed)/routes on the presence of the session cookie + a server-side session check against Raptor. The WebAuthn ceremony itself stays in Client Components only —navigator.credentialscannot be called from server-side code. - Credentials into infra, not into code.
.env.localis in.gitignore. The spec says what env vars exist; it does not populate them with real values. - Audit trail. No audit-relevant state changes happen in the frontend. Audit rows are written by Raptor/Queue. This is unchanged.
- Paper-first gating. No live-execution code path exists in Phase 0. N/A for this phase.
- GDPR. No new PII collection in the scaffold. The frontend has no persistent store of
PII (
localStorageholds UI preferences only, same as CRA).
Goals + non-goals
Goals (Phase 0)
frontend/raxx-next/exists and is independently installable.npm run devstarts on port 3000 with hot reload and API proxy.npm testruns Vitest unit tests.npm run test:e2eruns Playwright E2E tests (headless).- CE brand tokens are available system-wide via CSS custom properties.
- Feature flag client is available as
lib/featureFlags.ts. - The server-side route guard pattern is demonstrated in
middleware.ts+ a stubapp/(authed)/layout.tsx— this is the primary architectural deliverable of Phase 0. - A
MIGRATION.mddocuments what is and is not ported. frontend/trademaster_ui/is untouched and keeps working.
Non-goals (Phase 0)
- Porting any CRA page.
- CE chrome work beyond token plumbing.
- Deploying to CF Pages, Vercel, or any remote host.
- Making any CI check required.
- Visual regression testing.
- Enforcing a coverage threshold.
Package manager: npm over pnpm
The operator asked for a recommendation. This spec chooses npm (not pnpm) for Phase 0.
Reasoning:
- The CRA app uses npm; the CI runners have npm cached. Introducing pnpm in Phase 0 adds a toolchain variable to what is already a multi-track migration.
- pnpm's strict hoisting (
node_modules/.pnpm/) causes occasional compatibility issues with CRA-era packages (e.g.,react-scripts,craco, webpack plugin implicit peer deps). The Phase 0 dependencies are clean Next.js packages and are less prone to this — but the risk asymmetry favors npm while other things are in flux. - pnpm's disk-space savings (hard links) matter at scale, not for a single-developer migration scaffold.
- Revisit for Phase 1: once Phase 0 is confirmed stable, migrating
raxx-nextto pnpm is a one-PR change (npm install -g pnpm && pnpm import). The CI pipelines in Phase 1 can adopt pnpm from day one if the operator wants it.
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:
axios— use nativefetchin Next.js patterns. Port axios calls in Phase 2/3 page-by-page. Do not install axios in raxx-next until there is a specific need.react-router-dom— not needed. Next.js App Router handles routing.react-scripts— not needed. This is the CRA build tool.bootstrap/react-bootstrap— not in Phase 0. CE token system uses CSS custom properties. Bootstrap import is a per-page decision during Phase 3 porting.
Security + GDPR checklist
- PII collected in Phase 0: None. The scaffold has no backend calls in the stubs.
- Retention period: N/A for Phase 0.
- Deletion on DSR: N/A. No new PII stores.
- Audit trail: No auditable state changes in the scaffold. Raptor owns audit rows.
- Stored credentials: None. The session cookie is read server-side in middleware and RSC; it is never stored in JavaScript state. The WebAuthn private key lives in the browser's platform authenticator — not in Next.js code.
- Breach notification: No change from existing Raptor/Queue path.
- Secrets location:
NEXT_PUBLIC_*env vars are build-time, public, appropriate for client bundles (API URL, Sentry DSN). No secret values in.env.local.example. The.env.localfile is gitignored. - Kill-switch: In Phase 0, the Next.js app is local-only. There is nothing to kill. CF Pages rollback alias is the kill-switch for Phase 2+.
Open questions (none block Phase 0; all block Phase 1+)
-
Deployment target for Phase 2: CF Pages +
@cloudflare/next-on-pagesvs 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. -
Session cookie validation in middleware.ts Phase 1 upgrade:
GET /api/auth/sessioncalled 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. -
@cloudflare/next-on-pagescompatibility audit: Before committing to CF Pages as the Phase 2 target, feature-developer should runnpx @cloudflare/next-on-pages@1on 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. ```