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. Provides four Route Handler endpoints that proxy to api-server:3000 with byte-equivalent behavior to the existing Flask cm_web_view.py proxies — GET /api/acc/, GET /api/user/, POST /api/update-acc-data, POST /api/update-user-data. (B2 will start using these from React; B4 will retire the Flask versions.)
  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

Route Handler proxy

A single catch-all Route Handler at web/app/api/[...path]/route.ts forwards all /api/* requests to api-server:3000 (the upstream service name on the compose network). Implementation sketch:

import { NextRequest, NextResponse } from "next/server";

const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";

async function forward(request: NextRequest, path: string[]) {
  const targetPath = "/" + path.join("/");
  const trailingSlash = request.nextUrl.pathname.endsWith("/") ? "/" : "";
  const target = `${API_BASE_URL}${targetPath}${trailingSlash}`;

  const init: RequestInit = {
    method: request.method,
    headers: { "content-type": request.headers.get("content-type") ?? "application/json" },
  };
  if (request.method !== "GET" && request.method !== "HEAD") {
    init.body = await request.text();
  }

  try {
    const upstream = await fetch(target, init);
    const body = await upstream.text();
    return new NextResponse(body, {
      status: upstream.status,
      headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
    });
  } catch (err) {
    return NextResponse.json({ error: String(err) }, { status: 500 });
  }
}

export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
  return forward(req, (await ctx.params).path);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
  return forward(req, (await ctx.params).path);
}

Why catch-all instead of one file per endpoint:

  • The four endpoints today are mechanically identical proxies; writing four near-duplicate files is a YAGNI violation.
  • B2 may need additional methods or per-route logic; that's the time to split (and split only the routes that need it).
  • The error shape ({"error": str} with HTTP 500) matches the Flask version (return jsonify({"error": str(e)}), 500).

The trailing-slash preservation means GET /api/acc/ proxies to api-server:3000/acc/ (Flask's exact pattern); POST /api/update-acc-data proxies to api-server:3000/update-acc-data. Same shape as cm_web_view.py:proxy_acc(), proxy_update_acc(), etc.

Empty UI page

web/app/page.tsx renders a centered card confirming the service is up:

export default function Home() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-slate-50 p-8">
      <div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
        <h1 className="text-2xl font-semibold text-slate-900">cm-web-next scaffold</h1>
        <p className="mt-2 text-slate-600">
          B1 scaffold. UI lands in B2.
        </p>
        <p className="mt-4 text-sm text-slate-500">
          Try{" "}
          <a href="/api/acc/" className="text-blue-600 underline">
            /api/acc/
          </a>{" "}
          to verify the proxy reaches{" "}
          <code className="rounded bg-slate-100 px-1">api-server:3000</code>.
        </p>
      </div>
    </main>
  );
}

web/app/layout.tsx is the standard create-next-app boilerplate with <html lang="en">, body class for Tailwind defaults, and a tab title CM Bot V2.

web/app/globals.css is the Tailwind v4 import (@import "tailwindcss").

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/app/api/[...path]/route.ts Create — catch-all proxy
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.
  • Catch-all Route Handler regex semantics. Next.js's [...path] matches any depth; the explicit Flask routes are flat (e.g., /api/acc/). If any future api-server endpoint accidentally collides with a Next.js convention path (/api/_next/..., /api/__nextjs_original-stack-frames), behavior could be surprising. None of these conflicts exist today (api-server's routes are /acc/, /user/, /update-acc-data, /update-user-data).
  • 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.

Out-of-Scope Follow-Ups

  • B2 — port the UI: tabs, two tables, sortable columns, inline cell editing, refresh, stats cards, error states.
  • 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.