Next.js App Router
bkk-sports-club.jongyang.xyz shows the right shop) and the admin auth guard are all Next.js middleware.
File-System Routing
In Next.js App Router, the folder structure is the URL structure. No route configuration file needed:
frontend/app/
โโโ page.tsx โ https://jongyang.xyz/
โโโ layout.tsx โ wraps ALL pages
โโโ line-callback/
โ โโโ page.tsx โ https://jongyang.xyz/line-callback
โโโ shop/
โ โโโ [slug]/ โ dynamic segment
โ โโโ page.tsx โ https://jongyang.xyz/shop/bkk-sports-club
โ โโโ book/
โ โโโ page.tsx โ https://jongyang.xyz/shop/bkk-sports-club/book?...
โโโ admin/
โ โโโ login/
โ โ โโโ page.tsx โ https://jongyang.xyz/admin/login (any subdomain)
โ โโโ (panel)/ โ route group (no URL effect)
โ โโโ layout.tsx โ shared admin sidebar
โ โโโ page.tsx โ /admin/dashboard
โ โโโ bookings/
โ โโโ page.tsx โ /admin/bookings
โโโ api/
โโโ admin/
โโโ login/route.ts โ POST /api/admin/login
โโโ logout/route.ts โ POST /api/admin/logout
โโโ proxy/
โโโ [...path]/route.ts โ /api/admin/proxy/* (catch-all)
Dynamic Segments: [slug] and [...path]
[slug] matches any URL segment. [...path] (catch-all) matches any number of segments:
// app/shop/[slug]/page.tsx
export default async function ShopPage({ params }: { params: { slug: string } }) {
const slug = (await params).slug // "bkk-sports-club"
const shop = await fetch(`${API_URL}/api/shops/${slug}`).then(r => r.json())
return <ShopDetail shop={shop} />
}
// app/api/admin/proxy/[...path]/route.ts
export async function GET(req: NextRequest, { params }: { params: { path: string[] } }) {
const { path } = await params
// For /api/admin/proxy/bookings/123, path = ["bookings", "123"]
const backendPath = `/api/admin/${path.join('/')}`
// โ /api/admin/bookings/123
}
Server Components vs Client Components
This is the most important concept in Next.js App Router:
| Server Component | Client Component |
|---|---|
| Default โ no special marker needed | Must start with 'use client' |
| Runs on the server (Node.js) | Runs in the browser (after hydration) |
Can await data directly in the component | Must use useEffect to fetch data |
Cannot use hooks (useState, useEffect) | Can use all React hooks |
| Cannot access browser APIs | Can access window, document |
| Sent to browser as HTML (no JS bundle) | Included in the JS bundle |
// app/shop/[slug]/page.tsx โ SERVER component
export default async function ShopPage({ params }) {
const { slug } = await params
// โ
Can fetch directly โ no loading state needed
const shop = await fetch(`${API_URL}/api/shops/${slug}`, { cache: 'no-store' }).then(r => r.json())
return (
<div>
<h1>{shop.name_en}</h1>
<!-- BookingFlow is a CLIENT component imported here -->
<BookingFlow shop={shop} resource={resource} />
</div>
)
}
API Routes
Files named route.ts in the app/api/ directory become HTTP endpoints handled by Next.js server, not FastAPI. They're used as a secure intermediary layer:
export async function POST(request: NextRequest) {
let body: { email?: string; password?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
// Forward to FastAPI with the right format
const form = new URLSearchParams()
form.set('username', body.email!)
form.set('password', body.password!)
const res = await fetch(`${API_BASE}/api/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
})
if (!res.ok) return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
const { access_token } = await res.json()
// Set httpOnly cookie โ browser JS can never read this
const response = NextResponse.json({ ok: true })
response.cookies.set('admin_token', access_token, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24
})
return response
}
The proxy.ts Middleware
proxy.ts in the root of the frontend runs on every request before any page is rendered. It handles two responsibilities:
const BASE_DOMAIN = process.env.NEXT_PUBLIC_BASE_DOMAIN ?? 'localhost'
const RESERVED = new Set(['www', 'admin', 'api'])
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// โ Admin auth guard
if (pathname.startsWith('/admin')) {
const hasToken = !!request.cookies.get('admin_token')
if (pathname === '/admin/login') {
if (hasToken) return NextResponse.redirect(new URL('/admin/dashboard', request.url))
return NextResponse.next() // show login page
}
if (!hasToken) return NextResponse.redirect(new URL('/admin/login', request.url))
return NextResponse.next() // authenticated, continue
}
// โก Subdomain โ shop rewrite
const hostname = (request.headers.get('host') ?? '').split(':')[0]
const slug = hostname.replace(`.${BASE_DOMAIN}`, '')
// admin subdomain: redirect to /admin/login (skip /api/* paths)
if (slug === 'admin' && !pathname.startsWith('/api')) {
return NextResponse.redirect(new URL('/admin/login', request.url))
}
// shop subdomains: rewrite internally (but don't double-rewrite)
if (slug && slug !== BASE_DOMAIN && slug !== 'www' && !RESERVED.has(slug) && !pathname.startsWith('/shop/')) {
const url = request.nextUrl.clone()
const path = url.pathname === '/' ? '' : url.pathname
url.pathname = `/shop/${slug}${path}`
return NextResponse.rewrite(url) // URL in browser stays unchanged!
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], // matches everything except static files
}
Difference between rewrite and redirect:
NextResponse.rewrite(url)โ internally serves a different URL but the browser URL bar stays the same.bkk-sports-club.jongyang.xyzstays in the bar; internally serves/shop/bkk-sports-club.NextResponse.redirect(url)โ tells the browser to navigate to a new URL. The URL bar changes. Used for auth redirects (/admin/dashboard).
The Double-Rewrite Bug We Fixed
The shop page had links like href="/shop/bkk-sports-club/book?resource_id=...". When clicked from the subdomain:
Browser on: bkk-sports-club.jongyang.xyz
Clicks: href="/shop/bkk-sports-club/book?resource_id=..."
Request goes to: bkk-sports-club.jongyang.xyz/shop/bkk-sports-club/book?...
Middleware (BEFORE fix):
slug = "bkk-sports-club"
not in RESERVED
rewrites to: /shop/bkk-sports-club/shop/bkk-sports-club/book โ WRONG!
โ 404
Middleware (AFTER fix):
if (!pathname.startsWith('/shop/')) { rewrite }
pathname IS /shop/... so SKIP the rewrite
โ serves /shop/bkk-sports-club/book directly โ CORRECT!
NEXT_PUBLIC_* Environment Variables
Variables prefixed with NEXT_PUBLIC_ are special โ they're baked into the JavaScript bundle at build time. They're not read from the environment when the server starts:
# Build args passed at docker compose build time
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
ARG NEXT_PUBLIC_BASE_DOMAIN=localhost
# These become available to the build process
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_BASE_DOMAIN=$NEXT_PUBLIC_BASE_DOMAIN
RUN npm run build # โ The values are BAKED IN here. Changing them later has no effect.
NEXT_PUBLIC_* in .env without rebuilding the Docker image has zero effect. The values were baked in at npm run build. Always rebuild after changing these values.
๐ง Self-Check Quiz
1. What URL does app/shop/[slug]/book/page.tsx match? What does the [slug] part do?
/shop/{anything}/book โ for example /shop/bkk-sports-club/book or /shop/serenity-spa/book. The [slug] is a dynamic segment: Next.js captures whatever text appears at that position in the URL and makes it available as params.slug inside the page component.2. What's the main advantage of a server component over a client component for the shop page?
await data directly inside the component, without loading states or useEffect. The shop data is fetched server-side before HTML is sent to the browser โ the user sees content immediately, not a loading spinner. The component code also doesn't ship to the browser (smaller JS bundle). Client components must fetch data in the browser with useEffect, causing a visible loading state.3. What is the difference between NextResponse.rewrite() and NextResponse.redirect()?
bkk-sports-club.jongyang.xyz stays visible even though Next.js internally renders /shop/bkk-sports-club. Redirect: the browser navigates to the new URL โ the address bar changes. Used for auth redirects where you want the user to see the new URL (e.g., after logout, redirect to /admin/login).4. Why is the admin JWT stored in an httpOnly cookie set by a Next.js API route instead of directly from FastAPI's response?
/api/admin/login (Next.js), which forwards to FastAPI, gets the JWT, and sets it as an httpOnly cookie. The browser's JavaScript never sees the JWT token. If FastAPI set the cookie directly, it would need to be a cross-origin cookie (from api. subdomain to admin. subdomain), which has additional complexity. The Next.js proxy keeps everything on the same origin.5. You change NEXT_PUBLIC_API_URL in your .env file but the app still calls the old URL. Why, and what must you do?
NEXT_PUBLIC_* variables are baked into the JavaScript bundle at build time (when npm run build runs). The running container still has the old value compiled in. You must: (1) update .env, (2) run docker compose build nextjs to rebuild the image with the new value, (3) run docker compose up -d --force-recreate nextjs to restart the container with the new image.