โ† React Basics 10 / 18 Next: TypeScript โ†’

Next.js App Router

Module 4 ยท Frontend ยท โฑ ~35 min
Why this matters: Next.js is what converts a folder of React files into a real web app with URLs, server rendering, API routes, and middleware. The subdomain routing (why 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:

tsx โ€” using dynamic params in a page
// 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 ComponentClient Component
Default โ€” no special marker neededMust start with 'use client'
Runs on the server (Node.js)Runs in the browser (after hydration)
Can await data directly in the componentMust use useEffect to fetch data
Cannot use hooks (useState, useEffect)Can use all React hooks
Cannot access browser APIsCan access window, document
Sent to browser as HTML (no JS bundle)Included in the JS bundle
tsx โ€” server component (no 'use client', can await)
// 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:

tsx โ€” app/api/admin/login/route.ts
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:

tsx โ€” frontend/proxy.ts (full)
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:

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:

dockerfile โ€” frontend/Dockerfile
# 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.
Common mistake: Changing 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?

It matches /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?

Server components can 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()?

Rewrite: serves a different URL internally but the browser's address bar doesn't change. 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?

The Next.js API route acts as a secure intermediary. The browser's JS submits the form to /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.