cm_bot_v2/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md

18 KiB

B1: Next.js Scaffold + Side-by-Side Deploy + Proxy Parity Design

Date: 2026-05-02 Status: Approved (design) Sequel to: 2026-05-02-prod-hardening-c1-c5-c6-design.md Followed by: B2 (UI port), B3 (PWA), B4 (cutover — delete app/cm_web_view.py, rename cm-web-nextcm-web).

Problem

app/cm_web_view.py is a 758-line Flask app with inline HTML/CSS/JS. The user wants to migrate the UI to Next.js for a modern stack (App Router, React, Tailwind, PWA-ready). The full migration is multi-cycle; this cycle (B1) only ships the scaffold and deploy story — empty UI, parity-only proxies — so we can verify the build/deploy pipeline before pouring effort into the UI port (B2). The existing Flask UI keeps working in parallel during B2/B3.

Goal

Stand up a new cm-web-next Docker service running a Next.js 15 app at web/ in this repo, exposed on host port ${CM_WEB_NEXT_HOST_PORT:-8010} (default 8010 — non-conflicting with 8000/dev, 8001/rex, 8005/siong). The app:

  1. Renders a single placeholder page so the operator can confirm the service is up.
  2. Does not expose any public /api/* route. Browser-side data access in B2 will go through React Server Components (server-side fetch from api-server:3000 inside the compose network) and Server Actions (mutations) — no scrapable JSON endpoint. See "No public /api/* route" below.
  3. Builds reproducibly through scripts/publish.sh like the four existing services.
  4. Comes up automatically with bash scripts/dev.sh up.

The existing cm-web (Flask) service is untouched. Both services run side-by-side in dev and prod until B4 cuts over.

Non-Goals

  • Any actual UI work. The page reads <h1>cm-web-next scaffold</h1> plus a one-line confirmation that the API proxies are reachable. Tabs, tables, inline editing, sort, refresh — all in B2.
  • PWA / manifest.webmanifest / service worker / icons — all in B3.
  • Auth — handled by aaPanel (C3 basic auth covers the new vhost too).
  • Tests in B1. The scaffold is mostly config and glue; integration smoke is sufficient. B2 will introduce vitest when there's actual UI logic to test.
  • Migrating app/cm_web_view.py or removing the existing cm-web Flask service. B4's job after the UI is fully ported.
  • Renaming the cm-web-next service to cm-web during this cycle. The rename is part of B4 along with the Flask retirement.

Architecture

Repo layout

cm_bot_v2/
├── app/                           ← Python services (unchanged)
├── docker/
│   ├── api/, web/, telegram/, transfer/   ← Python service Dockerfiles
│   └── web-next/Dockerfile        ← NEW
├── web/                           ← NEW: full Next.js 15 project root
│   ├── package.json
│   ├── package-lock.json
│   ├── next.config.ts
│   ├── tsconfig.json
│   ├── tailwind.config.ts
│   ├── postcss.config.mjs
│   ├── .gitignore
│   ├── .dockerignore
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── globals.css
│   │   └── api/
│   │       └── [...path]/
│   │           └── route.ts       ← Catch-all proxy to api-server
│   └── public/                    ← (empty for B1; B3 adds icons)
├── docker-compose.yml             ← + cm-web-next service
├── docker-compose.override.yml    ← + cm-web-next build directive
├── envs/<name>/.env.example       ← + CM_WEB_NEXT_HOST_PORT
└── scripts/
    ├── dev.sh                     ← MODIFIED: include cm-web-next in up/logs
    └── publish.sh                 ← MODIFIED: + cm-web-next image

web/ is at the repo root (per L3 location decision), parallel to app/. It is its own JavaScript/TypeScript project with its own package.json, completely independent of the Python tree.

Tech stack

Choice Value Rationale
Next.js 15.x stable Modern, App Router, Route Handlers
Language TypeScript Next.js default; tooling assumes it
Styling Tailwind CSS v4 What create-next-app ships in 2026
Routing App Router (app/ dir) Modern; B2/B3 conventions cleaner
Build output output: 'standalone' in next.config.ts Slim runtime image (~150MB vs 1GB+)
Package manager npm Zero ceremony, default tooling
Node 22 LTS (in container) Current LTS line
Trailing slashes trailingSlash: true Parity with Flask @app.route('/api/acc/') paths

No public /api/* route — RSC + Server Actions architecture

The Flask cm_web_view.py exposed four JSON proxy endpoints to the browser (/api/acc/, /api/user/, /api/update-acc-data, /api/update-user-data). The Next.js rewrite does not replicate them as public routes. There's no third-party API consumer (the bot CLI talks to api-server directly inside the compose network, not through the web), so a public JSON surface is pure attack surface with no upside.

Instead:

  • Reads (B2) happen inside React Server Components. app/page.tsx and friends are server-rendered; their server-side fetch("http://api-server:3000/acc/") calls run on the Next.js server inside the docker network. The browser only ever receives the rendered HTML / RSC payload — no JSON endpoint to call.
  • Writes (B2) go through Next.js Server Actions: "use server" async functions called from React components. Next.js auto-handles the wire format (POST to /<page> with magic encoded payload, processed server-side and routed by the framework). The browser never sees a /api/* path.
  • api-server stays internal as designed in C5 (no host port published). web-next reaches it as api-server:3000 via the compose network, same as web-view does today.

Net result: zero public JSON endpoints, zero scrapable paths beyond / and Server Actions' opaque internal routes. The hash-encoded URL scheme that earlier drafts proposed is gone — there's nothing to obfuscate when nothing is public.

For B1 specifically there is no Route Handler, no web/app/api/ directory, no web/lib/api-paths.ts, and no smoke-test API call. The placeholder page documents the architecture so the next reader understands the choice.

Empty UI page

web/app/page.tsx renders a placeholder confirming the service is up. The implementation plan invokes the frontend-design skill to generate page.tsx and layout.tsx (per user direction — frontend-design owns all web design code in this codebase). The brief handed to the skill:

  • Page purpose: scaffold-confirmation page for cm-web-next. Shipped only in B1; replaced by the real dashboard in B2.
  • Required content: product name (CM Bot V2), the literal text cm-web-next scaffold so an operator can grep the page for it, a one-line "B2 lands the real UI" note, and an obvious link to /api/acc/ so a smoke test of the proxy is one click away.
  • Design constraints: uses Tailwind v4 (already configured); no external font/image deps; no JS interactivity (Server Component is fine); single page, no nav. Should clearly read as a temporary scaffold (not a real dashboard) so nobody mistakes it for production UI.
  • Out of scope: dark mode, responsive breakpoints beyond mobile-first defaults, animations.

web/app/layout.tsx is also generated by frontend-design — minimal <html lang="en"> shell with <body> Tailwind defaults and tab title CM Bot V2.

web/app/globals.css is the Tailwind v4 import (@import "tailwindcss";) — written by hand, no design surface.

Dockerfile

Multi-stage build at docker/web-next/Dockerfile, modeled after Next.js's official Dockerfile example. Uses output: 'standalone' so the runtime image only carries the standalone server bundle (~150MB total).

# syntax=docker/dockerfile:1.7

# --- deps ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY web/package.json web/package-lock.json* ./
RUN npm ci

# --- build ---
FROM node:22-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY web/ ./
RUN npm run build

# --- runtime ---
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

The standalone server listens on $PORT. We expose 3000 inside the container and bind to a host port via compose (default 8010 → container 3000). Naming note: container port 3000 is internal; nothing else at this scale uses 3000 because api-server's host port was dropped in C5.

next.config.ts

import type { NextConfig } from "next";

const config: NextConfig = {
  output: "standalone",
  trailingSlash: true,
};

export default config;

Compose changes

docker-compose.yml — add cm-web-next service near the existing web-view:

  # Next.js Web View (side-by-side with web-view during B-cycle migration).
  web-next:
    image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}"
    container_name: ${CM_DEPLOY_NAME:-cm}-web-next
    restart: unless-stopped
    ports:
      - "${CM_WEB_NEXT_HOST_PORT:-8010}:3000"
    environment:
      NODE_ENV: production
      NEXT_TELEMETRY_DISABLED: "1"
      API_BASE_URL: http://api-server:3000
    networks:
      - bot-network
    depends_on:
      - api-server

docker-compose.override.yml — add a web-next block with build directive (parallel to existing services):

  web-next:
    build:
      context: .
      dockerfile: docker/web-next/Dockerfile
    image: "${CM_IMAGE_PREFIX:-local}/cm-web-next:${DOCKER_IMAGE_TAG:-dev}"
    # No `command:` override — Next.js standalone server is the dev runtime
    # for B1. (B2 may add a `command: ["npm", "run", "dev"]` to enable
    # hot-reload; not in this cycle.)

The compose service name is web-next (not cm-web-next) for parity with the existing pattern (web-view, api-server, telegram-bot, transfer-bot). Container name resolves to ${CM_DEPLOY_NAME}-web-next via the existing pattern.

Env file changes

Add CM_WEB_NEXT_HOST_PORT to all three .env.example templates, with deployment-appropriate defaults:

File New line
envs/dev/.env.example CM_WEB_NEXT_HOST_PORT=8010
envs/rex/.env.example CM_WEB_NEXT_HOST_PORT=8011
envs/siong/.env.example CM_WEB_NEXT_HOST_PORT=8012

The committed templates document the convention; each operator's gitignored .env is updated by hand (or by cp envs/<name>/.env.example .env for fresh clones).

scripts/dev.sh

Modify the up, logs, and up invocation in reset-db to include web-next:

  up)
    "${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
    "${COMPOSE[@]}" ps
    ;;
  reset-db)
    "${COMPOSE[@]}" down --volumes --remove-orphans
    "${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
    ;;
  logs)
    "${COMPOSE[@]}" logs -f mysql api-server web-view web-next
    ;;

scripts/publish.sh

Append web-next to the SERVICES array:

SERVICES=(
  "api docker/api/Dockerfile"
  "telegram docker/telegram/Dockerfile"
  "web docker/web/Dockerfile"
  "transfer docker/transfer/Dockerfile"
  "web-next docker/web-next/Dockerfile"
)

The image name template ${REGISTRY_PREFIX}/cm-${SERVICE}:${IMAGE_TAG} produces gitea.04080616.xyz/yiekheng/cm-web-next:<tag>, matching the compose image: reference.

AGENTS.md updates

  • Add a bullet under "Project Structure & Module Organization" describing web/ and the side-by-side cm-web-next service.
  • Add a one-line note under "Dev Tier" pointing to http://localhost:8010/ for the new UI alongside http://localhost:8000/ for the legacy Flask UI.

web/.gitignore and web/.dockerignore

Standard Next.js ignores. web/.gitignore includes .next/, node_modules/, build/test outputs. web/.dockerignore excludes node_modules/, .next/, .git/, and the rest of the repo (we only need web/ in the build context, but Docker copies what we ask for via the explicit COPY web/... line).

Files Created / Modified

File Operation
web/package.json Create — Next.js 15 deps, scripts
web/package-lock.json Create — generated by npm install
web/next.config.ts Create — standalone + trailingSlash
web/tsconfig.json Create — create-next-app defaults
web/tailwind.config.ts Create — Tailwind v4 default
web/postcss.config.mjs Create — Tailwind v4 PostCSS plugin
web/app/layout.tsx Create — title + Tailwind globals
web/app/page.tsx Create — placeholder card
web/app/globals.css Create — @import "tailwindcss";
web/.gitignore Create — Next.js standard
web/.dockerignore Create — exclude node_modules / .next
docker/web-next/Dockerfile Create — multi-stage Node 22 alpine
docker-compose.yml Modify — add web-next service
docker-compose.override.yml Modify — add web-next build directive
envs/dev/.env.example Modify — CM_WEB_NEXT_HOST_PORT=8010
envs/rex/.env.example Modify — CM_WEB_NEXT_HOST_PORT=8011
envs/siong/.env.example Modify — CM_WEB_NEXT_HOST_PORT=8012
scripts/dev.sh Modify — include web-next in up/logs/reset-db
scripts/publish.sh Modify — append web-next to SERVICES
AGENTS.md Modify — note new service + UI URL

No file removals. Nothing in app/ is touched.

Verification

  1. web/ builds locally without docker. cd web && npm install && npm run build succeeds (smoke for someone editing TS without rebuilding the container each time).
  2. bash scripts/dev.sh up brings up five services. mysql, api-server, web-view (Flask, port 8000), web-next (Next.js, port 8010). docker compose ps shows all five running.
  3. Empty page renders. curl -s http://localhost:8010/ | grep -E "cm-web-next scaffold|B1 scaffold" returns hits. Open in a browser → centered card visible with the link.
  4. Proxy parity. curl -s http://localhost:8010/api/acc/ | head -c 200 returns the same JSON shape as curl -s http://localhost:8000/api/acc/ | head -c 200 (both proxy to api-server:3000/acc/).
  5. POST proxy. curl -i -X POST -H 'Content-Type: application/json' -d '{"username":"13c1000","password":"x","status":"","link":""}' http://localhost:8010/api/update-acc-data returns the same response (and same exit status) as the same POST against the Flask web-view on port 8000.
  6. Image publishes. bash scripts/publish.sh dev publishes cm-web-next:dev alongside the other four images. (Skip in CI; smoke check on the operator's machine when ready.)
  7. Old cm-web still works. curl -s http://localhost:8000/ still returns the Flask HTML page. Side-by-side preserved.
  8. Prod parity check. docker compose -f docker-compose.yml config | grep -E "web-next" | head shows the new service. docker compose -f docker-compose.yml config | grep "ports:" -A 1 confirms web-next is on ${CM_WEB_NEXT_HOST_PORT:-8010}:3000 and web-view is unchanged on ${CM_WEB_HOST_PORT:-8001}:8000.

Risk

Low.

  • Side-by-side means double the surface for now. Two web services running, two host ports, two images. The cost is real but bounded — once B2 is feature-complete and B4 cuts over, the old cm-web retires and we're back to one. The alternative (in-place rewrite) was higher risk because broken B2 commits would break prod.
  • No public /api/* route — be aware before adding one. B1 explicitly does not expose JSON endpoints to the browser. If a future need surfaces (a third-party consumer, a mobile native client, a webhook callback), revisit the architecture deliberately rather than adding a Route Handler ad-hoc. The threat model assumed in this design is "internal CRUD only, browser is the only consumer."
  • Trailing slashes. trailingSlash: true in Next.js means /api/acc redirects to /api/acc/ with a 308. The Flask version always required the slash. Behavior is parity-equivalent for clients that include the slash; clients that don't get a redirect they didn't get before. The UI we control will always include it.
  • Build context size. Docker build context includes the whole repo unless we set up .dockerignore. The repo's existing .dockerignore excludes __pycache__, *.py[cod], *.log, .git, logs/, node_modules/. The node_modules/ entry already prevents copying the host's web/node_modules/ if the operator ran npm install on the host — good. We don't need a new repo-root .dockerignore change.

Frontend-design conventions

All web design code in this codebase (TSX components, page layouts, CSS-in-class Tailwind, etc.) goes through the frontend-design skill rather than being hand-written. This decision applies to B1's placeholder, B2's full UI port, and any subsequent UI work. Glue code (Route Handlers, next.config.ts, env wiring, the Dockerfile, package.json, etc.) is written directly.

Practical implication for the implementation plan: there is one task that explicitly invokes frontend-design with the brief described in the "Empty UI page" section above and writes the returned page.tsx and layout.tsx into web/app/. Subsequent edits to those files in B2 follow the same pattern.

Out-of-Scope Follow-Ups

  • B2 — port the UI: tabs, two tables, sortable columns, inline cell editing, refresh, stats cards, error states. Implementation invokes frontend-design.
  • B3 — PWA: manifest.webmanifest, next-pwa (or hand-rolled service worker), 192/512 icons, install prompt.
  • B4 — cutover: delete app/cm_web_view.py, retire cm-web service, rename cm-web-nextcm-web (and same for the image), update aaPanel-hardening guide and dev.sh accordingly.
  • Hot-reload in devcommand: ["npm", "run", "dev"] in the override + bind-mount web/ into the container. Useful but not necessary for B1; revisit when B2 starts and iteration speed matters.
  • Vitest — unit tests for components/route handlers. B2 lays the foundation when there's actual logic.