Next.js‑based frontend for Aimer. Provides two apps: Admin and User.
- Prerequisites
- Port Configuration
- Networking & GraphQL Proxy
- Development
- Deployment
- Scripts
- CI
- Quality Checks (AI‑Assisted Coding)
- Integration Test (Real Server)
- Internationalization (next-intl)
- Tech Highlights
- Node 22: Use Node.js 22.x. Do not use Node 24.
- nvm example:
nvm install 22 && nvm use 22 - macOS (Homebrew):
brew update && brew install node@22- Unlink the previous version:
brew unlink node - Link 22:
brew link --overwrite --force node@22 - Verify:
node -vshould print v22.x.y
- nvm example:
- pnpm: Use pnpm 10+.
- macOS (Homebrew):
brew install pnpm
- Linux (Corepack with Node 22):
corepack enablecorepack prepare pnpm@10 --activate
- Windows (Corepack with Node 22):
corepack enablecorepack prepare pnpm@10 --activate
- macOS (Homebrew):
- Next.js: Use 15 (latest patch) for Node 22 compatibility; do not use 16.
Installed via
pnpm install. - Docker: Install Docker (Docker Desktop or Docker Engine).
- Biome CLI 2.x (Rust binary) available on your
PATH– download a release build and placebiomesomewhere executable, or compile it yourself via Cargo following the Biome documentation.
- If your Aimer backend uses a different port than the default 8445, update the configuration accordingly.
- By default, aimer-web runs on port 8446. You can change this port if necessary.
This project can run with an optional edge proxy (Nginx) in front of the app, resulting in a two-layer reverse proxy model. If Nginx is not used, only the in‑app proxy (Next.js API route) is active.
- Edge proxy (if used): Nginx
- HTTPS (production):
- Host → container:
8446 → 443(TLS terminated at Nginx) - Nginx:
listen 443 ssl;→proxy_pass http://web:3000 - Use the
httpsprofile; production must serve HTTPS.
- Host → container:
- HTTP (development):
- Host → container:
8446 → 8080 - Nginx:
listen 8080;→proxy_pass http://web:3000 - You may also skip Nginx entirely and access the app directly.
- Host → container:
- Purpose: stable external port, HTTPS termination for production, keep the app container private.
- Note: when Nginx is used, the Next.js app (service
web) listens on internal port3000and Nginx proxies toweb:3000. When accessing Next.js directly without Nginx in local development, it listens on8446(pnpm dev -p 8446).
- HTTPS (production):
- In-app proxy: Next.js API route
- Route:
/api/graphqlatsrc/app/api/graphql/route.ts. - Role: receive browser requests and forward them server-side to the real
GraphQL upstream (
AIMER_GRAPHQL_ENDPOINT). - Auth: reads the HttpOnly cookie
aimer_tokenand attachesAuthorization: Bearer <token>to the upstream call.
- Route:
Flow overview
- Browser → (optional Nginx) → Next.js →
/api/graphql→ Aimer GraphQL upstream - Without Nginx, the browser talks directly to the Next.js app; the
/api/graphqlbehavior is the same.
Port behavior by scenario
- Local development (no Nginx):
pnpm dev -p 8446→ Next.js listens on 8446 directly. - Docker single container (no Nginx): Next.js listens on 3000 in the container;
host maps
8446:3000. - Docker Compose with Nginx:
- HTTP profile: host 8446 → Nginx 8080 → Next.js
web:3000. - HTTPS profile: host 8446 → Nginx 443 (TLS) → Next.js
web:3000.
- HTTP profile: host 8446 → Nginx 8080 → Next.js
Why this matters
- CORS simplicity: the browser calls same-origin
/api/graphql, so CORS doesn’t trigger. - Security: token stays in an HttpOnly cookie; only the server attaches it to upstream requests.
- Required (current design): set
NEXT_PUBLIC_GRAPHQL_ENDPOINTto/api/graphqlonly.- Rationale: ensures the browser always hits the in-app proxy so the server can
read the HttpOnly cookie and add
Authorizationsecurely.
- Rationale: ensures the browser always hits the in-app proxy so the server can
read the HttpOnly cookie and add
- What if you set an absolute URL (e.g.,
https://api.example.com/graphql)?- Behavior: the browser calls the upstream directly, bypassing the in-app proxy.
In that case
AIMER_GRAPHQL_ENDPOINTis not used. - To make this work correctly, additional changes are required on both sides:
- On Aimer (upstream):
- CORS: allow your app’s origin explicitly, and if cookies are used, set
Access-Control-Allow-Credentials: true(no wildcard origin). - Cookies (if using cookie auth): issue cookies with
Domain=.example.com,SameSite=None; Secureso cross-site cookies can be sent.
- CORS: allow your app’s origin explicitly, and if cookies are used, set
- On aimer-web (this app):
- Client fetch: use
credentials: 'include'for cookie-based auth; or - Switch to a JS-managed bearer token (less secure than HttpOnly), and ensure
the API allows the
Authorizationheader.
- Client fetch: use
- On Aimer (upstream):
- Status: this absolute-URL mode is not enabled by default. We may consider it
later; for now, use
/api/graphql.
- Behavior: the browser calls the upstream directly, bypassing the in-app proxy.
In that case
-
Use a shared pnpm store across repos:
pnpm config set store-dir ~/.pnpm-store --global
-
Install dependencies:
pnpm install
-
(Important) Approve build scripts (required for pnpm v9+):
pnpm approve-builds
-
Install Playwright browsers (one-time per machine):
pnpm exec playwright install --with-deps- Linux (e.g., CI runners):
--with-depsalso installs required system packages so browsers run out of the box. - macOS: the flag is effectively a no-op; it only downloads the browser binaries, so leaving it on is harmless.
- Linux (e.g., CI runners):
-
Provide environment variables (
pnpm run devreads from.env.localor the current shell). Copy.env.local.exampleto.env.localand replace the placeholders:NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/graphql(client → built-in proxy)AIMER_GRAPHQL_ENDPOINT=https://<your-graphql-host>/graphql(upstream)- Example for local dev:
https://127.0.0.1:8445/graphql
- Example for local dev:
- Optionally
INSECURE_TLS=1for local self‑signed upstream
-
Run dev:
pnpm devthen openhttp://localhost:8446- Note:
pnpm devis shorthand forpnpm run dev. pnpm treats script names as direct commands, so both execute the samedevscript frompackage.json.
- Note:
For local evaluation and testing. Not hardened for production.
Runs the production Next.js server inside a single container (no Nginx).
-
Build (inject client endpoint at build time):
docker build -t aimer-web:latest \ --build-arg NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/graphql . -
Run (set upstream endpoint at runtime):
-
macOS
docker run --rm -p 8446:3000 \ --name aimer-web \ -e NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/graphql \ -e AIMER_GRAPHQL_ENDPOINT=https://host.docker.internal:8445/graphql \ -e INSECURE_TLS=1 \ aimer-web:latest
-
Linux (add host mapping)
docker run --rm -p 8446:3000 \ --name aimer-web \ --add-host=host.docker.internal:host-gateway \ -e NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/graphql \ -e AIMER_GRAPHQL_ENDPOINT=https://host.docker.internal:8445/graphql \ -e INSECURE_TLS=1 \ aimer-web:latest
-
-
Access:
http://localhost:8446
-
Verify:
docker ps | grep aimer-webdocker logs -f aimer-web
-
Notes:
- Use
/api/graphqlso the client calls the built‑in proxy. The server then callsAIMER_GRAPHQL_ENDPOINTand can optionally skip TLS verification withINSECURE_TLS=1(local/self‑signed only). - If you set
NEXT_PUBLIC_GRAPHQL_ENDPOINTto an external URL instead, you must ensure proper CORS and a trusted certificate; otherwise the browser will block requests.
- Use
Use Nginx as a reverse proxy to the Next.js app (HTTP). For HTTPS, use the dedicated HTTPS profile with your own certificate files.
-
Configure env (
.envin repo root):NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/graphql(client → built‑in proxy)AIMER_GRAPHQL_ENDPOINT=https://<your-graphql-host>/graphql(upstream)- If your GraphQL server runs on the host, set
AIMER_GRAPHQL_ENDPOINT=https://host.docker.internal:8445/graphqlin.env. On Linux, this works viaextra_hosts: ["host.docker.internal:host-gateway"](already configured).
- If your GraphQL server runs on the host, set
- Optional (local/self‑signed upstream):
INSECURE_TLS=1 - Tip: copy from
.env.example
-
Build and start:
docker compose --profile http up --build -d
-
Access:
http://localhost:8446
-
Verify:
docker compose psdocker compose logs -f nginx-http
-
Notes:
- Nginx config:
docker/nginx/default.conf(proxies toweb:3000)
- Nginx config:
Recommended for real services exposed to users; includes HTTPS.
Terminate TLS at Nginx and proxy to the Next.js app.
-
Configure env (
.envin repo root):NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/graphqlAIMER_GRAPHQL_ENDPOINT=https://<your-graphql-host>/graphql- If your GraphQL server runs on the host, set
AIMER_GRAPHQL_ENDPOINT=https://host.docker.internal:8445/graphqlin.env. On Linux, this works viaextra_hosts: ["host.docker.internal:host-gateway"](already configured).
- If your GraphQL server runs on the host, set
- Optional (local/self‑signed upstream):
INSECURE_TLS=1
-
Place certificates:
docker/nginx/certs/fullchain.pemdocker/nginx/certs/privkey.pem
Options to obtain/place certificates:
-
Use existing certificates (already issued on the host with your preferred tool)
-
Copy files into the repo mount path:
mkdir -p docker/nginx/certs cp /path/to/fullchain.pem docker/nginx/certs/ cp /path/to/privkey.pem docker/nginx/certs/
-
Or mount original paths instead of copying (edit
docker-compose.https.yml).
-
-
Generate a self‑signed certificate for testing (OpenSSL example)
-
OpenSSL (quick local cert for localhost):
mkdir -p docker/nginx/certs openssl req -x509 -nodes -newkey rsa:2048 -days 365 \ -keyout docker/nginx/certs/privkey.pem \ -out docker/nginx/certs/fullchain.pem \ -subj "/CN=localhost"
-
-
Set secure file permissions (optional but good practice):
chmod 644 docker/nginx/certs/fullchain.pem chmod 600 docker/nginx/certs/privkey.pem
-
Notes:
*.pemfiles are ignored by Git (see.gitignore). Do not commit secrets.- For real domains, prefer valid public CAs over self‑signed certs.
-
Build and start:
docker compose --profile https up --build -d
-
Access:
https://localhost:8446
-
Verify:
docker compose psdocker compose logs -f nginx-https
-
Notes:
- Nginx config:
docker/nginx/default-ssl.conf(HTTP→HTTPS redirect included)
- Nginx config:
pnpm dev: Run Next.js dev serverpnpm build/pnpm start: Production build and startpnpm lint: Check code style with Biomepnpm format: Auto‑format with Biomepnpm typecheck: TypeScript check only (tsc --noEmit)pnpm test: Unit/component tests with Vitest (jsdom)pnpm run test:int: Integration test for GraphQL sign‑in (see below)
GitHub Actions workflow runs on push and PR:
- Biome check: style/lint/format (fails on mismatch)
- Type check:
tsc --noEmitwith strict options - Tests: Vitest (unit/component tests)
Workflow file: .github/workflows/ci.yml
Because we will generate code frequently via AI agents, this project enforces strong, automated checks to keep quality high and regressions low:
- Biome: one tool for lint + format
- Local:
pnpm run format:check(orpnpm lint), auto‑fix:pnpm run lint:fix/pnpm format - CI: fails if formatting or lint rules are violated
- Local:
- TypeScript: strict + extra safety flags
strict: true,noUnusedLocals,noUnusedParameters,noFallthroughCasesInSwitch- Local:
pnpm typecheck(runstsc --noEmit)
- Tests: Vitest + React Testing Library
- Local:
pnpm test(single‑thread pool configured for stability) - Integration (opt‑in):
pnpm run test:int(calls real GraphQL if env vars present)
- Local:
- Markdown:
markdownlintchecks docs consistency - CI gating: tests run only after checks pass (
needs: check) - Build validation: CI builds the Next.js app and Docker image, and verifies Nginx
configs with
nginx -t
There is an opt‑in sign‑in integration test that calls your real GraphQL API:
- Test file:
__tests__/signin.int.test.ts - Dedicated config:
vitest.int.config.ts
- Run (macOS/Linux):
NEXT_PUBLIC_GRAPHQL_ENDPOINT=https://<host>/graphql TEST_USERNAME=<u> TEST_PASSWORD=<p> pnpm run test:int
Notes:
- The integration test setup (
__tests__/setup.int.ts) disables TLS verification only for this test run to make local/self‑signed endpoints workable. Do not use this in production. - For browser sign‑in at
/[locale]/signin(e.g.,/en/signin), use a valid certificate or a proxy route; browsers cannot bypass TLS verification programmatically.
- URL locales:
/en/...and/ko/...usingapp/[locale]/routing. - Middleware:
src/middleware.tsredirects bare paths to the preferred/<locale>/...based on Accept-Language. - Provider:
src/app/[locale]/layout.tsxwraps the tree withNextIntlClientProviderusinggetMessages()andgetLocale(). - Messages: flat JSON at project root
messages/en.json,messages/ko.jsonfor AI‑friendly editing.- At request time, flat keys are converted to nested objects via
src/i18n/messages.ts#nestMessages(required by next‑intl/use‑intl). Loader:src/i18n/request.ts.
- At request time, flat keys are converted to nested objects via
- Server components: use
await getTranslations(); Client components: useuseTranslations(). - Language switcher:
src/components/LanguageSwitcher.tsxupdates the locale segment in the current URL while preserving the query string. - Navigation helpers:
src/i18n/navigation.tsexports{Link, useRouter, usePathname}fromcreateSharedPathnamesNavigation({locales: ['en','ko']}). Use these for locale-aware internal links (URLs include the current locale without relying on middleware redirects).
- Add/modify messages in
messages/en.jsonandmessages/ko.jsonwith flat keys likesignin.title. - Access in components:
- Server:
const t = await getTranslations(); t('signin.title') - Client:
const t = useTranslations(); t('signin.title')
- Server:
- Links:
import {Link} from '@/i18n/navigation'and use<Link href="/signin" />to get locale-prefixed URLs automatically.
// Localized Link (client or server component)
import {Link} from "@/i18n/navigation";
export function Actions() {
return (
<div>
<Link href="/signin?mode=user">User Sign In</Link>
<Link href="/signin?mode=admin">Admin Sign In</Link>
</div>
);
}// Language switcher in layout
// Note: LanguageSwitcher is a Client Component that reads `useTranslations()` internally.
import {LanguageSwitcher} from "@/components/LanguageSwitcher";
export default function LocaleLayout({children}: {children: React.ReactNode}) {
return (
<html>
<body>
<header>
<LanguageSwitcher />
</header>
{children}
</body>
</html>
);
}- Routes: visit
/en/signinor/ko/signinto see translated UI.
- The unit tests demonstrate both client and server i18n usage:
- Client:
__tests__/i18n.client.test.tsxwraps components withNextIntlClientProviderusingnestMessages(en). - Server:
__tests__/i18n.server.test.tsxmocksnext-intl/serverand feeds nested messages derived from the flat JSON. - Routes:
__tests__/i18n.routes.test.tsxverifies locale-prefixed links and localized rendering for/enand/kopages (home and sign-in).
- Client:
- Rationale: Given the current scope and pace of this app, minimizing complexity outweighs the benefits of deeply nested message files. Flat keys are simpler to scan, easier to diff/review, and AI-friendly to generate and edit. The runtime converts them to the nested shape that next-intl expects.
- Edit locations: Only update
messages/en.jsonandmessages/ko.json. - Key format: Use flat, sectioned keys (e.g.,
signin.title,profile.greeting). - No mixing: Avoid conflicting paths such as using both
a.banda.b.cin the same file. - Values: Must be strings (objects/arrays are not supported as message values).
- ICU: Use ICU placeholders for variables and plurals (e.g.,
{name},{count}). - Runtime nesting: Flat keys are transformed at request time via
src/i18n/messages.ts#nestMessages(loader:src/i18n/request.ts). No separate nested files are maintained. - Example:
- en.json / ko.json
"profile.greeting": "Hello, {name}!""profile.greeting": "안녕하세요, {name}!"
- Server:
const t = await getTranslations(); t('profile.greeting', { name }) - Client:
const t = useTranslations(); t('profile.greeting', { name })
- en.json / ko.json
- Next.js App Router (server components by default)
- Tailwind CSS v4, minimal shadcn‑style UI components (Button/Input)
- React Hook Form + Zod validation on the sign‑in form
- GraphQL client via
graphql-request(NEXT_PUBLIC_GRAPHQL_ENDPOINT)