# 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](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-next` → `cm-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 `

cm-web-next scaffold

` 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//.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: ```typescript 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 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 `` shell with `` 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). ```dockerfile # 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` ```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`: ```yaml # 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): ```yaml 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//.env.example .env` for fresh clones). ### `scripts/dev.sh` Modify the `up`, `logs`, and `up` invocation in `reset-db` to include `web-next`: ```bash 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: ```bash 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:`, 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. ## 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-next` → `cm-web` (and same for the image), update aaPanel-hardening guide and `dev.sh` accordingly. - **Hot-reload in dev** — `command: ["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.