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-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:
- Renders a single placeholder page so the operator can confirm the service is up.
- Does not expose any public
/api/*route. Browser-side data access in B2 will go through React Server Components (server-side fetch fromapi-server:3000inside the compose network) and Server Actions (mutations) — no scrapable JSON endpoint. See "No public /api/* route" below. - Builds reproducibly through
scripts/publish.shlike the four existing services. - 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.pyor removing the existingcm-webFlask service. B4's job after the UI is fully ported. - Renaming the
cm-web-nextservice tocm-webduring 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.tsxand friends are server-rendered; their server-sidefetch("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-nextreaches it asapi-server:3000via the compose network, same asweb-viewdoes 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 textcm-web-next scaffoldso 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-sidecm-web-nextservice. - Add a one-line note under "Dev Tier" pointing to
http://localhost:8010/for the new UI alongsidehttp://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
web/builds locally without docker.cd web && npm install && npm run buildsucceeds (smoke for someone editing TS without rebuilding the container each time).bash scripts/dev.sh upbrings up five services.mysql,api-server,web-view(Flask, port 8000),web-next(Next.js, port 8010).docker compose psshows all fiverunning.- 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. - Proxy parity.
curl -s http://localhost:8010/api/acc/ | head -c 200returns the same JSON shape ascurl -s http://localhost:8000/api/acc/ | head -c 200(both proxy toapi-server:3000/acc/). - POST proxy.
curl -i -X POST -H 'Content-Type: application/json' -d '{"username":"13c1000","password":"x","status":"","link":""}' http://localhost:8010/api/update-acc-datareturns the same response (and same exit status) as the same POST against the Flaskweb-viewon port 8000. - Image publishes.
bash scripts/publish.sh devpublishescm-web-next:devalongside the other four images. (Skip in CI; smoke check on the operator's machine when ready.) - Old
cm-webstill works.curl -s http://localhost:8000/still returns the Flask HTML page. Side-by-side preserved. - Prod parity check.
docker compose -f docker-compose.yml config | grep -E "web-next" | headshows the new service.docker compose -f docker-compose.yml config | grep "ports:" -A 1confirmsweb-nextis on${CM_WEB_NEXT_HOST_PORT:-8010}:3000andweb-viewis 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-webretires 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: truein Next.js means/api/accredirects 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.dockerignoreexcludes__pycache__,*.py[cod],*.log,.git,logs/,node_modules/. Thenode_modules/entry already prevents copying the host'sweb/node_modules/if the operator rannpm installon the host — good. We don't need a new repo-root.dockerignorechange.
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, retirecm-webservice, renamecm-web-next→cm-web(and same for the image), update aaPanel-hardening guide anddev.shaccordingly. - Hot-reload in dev —
command: ["npm", "run", "dev"]in the override + bind-mountweb/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.