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.
- Provides four Route Handler endpoints that proxy to
api-server:3000with byte-equivalent behavior to the existing Flaskcm_web_view.pyproxies —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.) - 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 |
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-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/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
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. - 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: 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.
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, 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.