บทเรียน 83 · Frontend · Next.js App

Layout ของแอป — Container, Navbar และ app/layout.tsx

~25 นาที · ควรผ่าน บท 70 (layout.tsx กับ page.tsx) · บท 72 (Tailwind) · บท 82 มาก่อน

ถึงตอนนี้แอป Wongnok มีปุ่มสวย ๆ แล้ว แต่ยังไม่มีโครงของหน้า — ไม่มีแถบเมนูด้านบน ไม่มีท้ายหน้า ทุกหน้าลอยอยู่เปล่า ๆ
สไลด์หน้า 33–40 สร้างโครงนั้น: แบ่งหน้าเป็นส่วน ๆ · เขียน Navbar · แล้วเอาไปวางใน app/layout.tsx ให้โผล่ทุกหน้า
โค้ดในสไลด์สั้นและดูเรียบร้อย แต่มีห้าจุดที่สไลด์ไม่ได้บอก และผมรันให้ดูทุกจุด: Container ไม่มีใครนิยามไว้ · fixed ทำให้หัวข้อแรกหายไปใต้แถบเมนู · link.active ไม่มีที่มา · <Link> ต่างจาก <a> ตรงไหน · และทำไมเมนูหายไปเฉย ๆ บนมือถือ

🧰 ของที่ใช้ตรวจในบทนี้

Next.js      16.3.5 (Turbopack)   # บรรทัดแรกของ npx next build
react        19.2.8
typescript   5.9.3
tailwindcss  4.3.3
node         v24.14.0
โปรเจกต์     create-next-app + shadcn init (preset base-nova) + button card badge skeleton

โค้ดทุกก้อนอยู่ในโปรเจกต์ Next.js จริง ผ่าน npx next build · npx tsc --noEmit · npx eslint . (ต่อ echo rc=$? ทุกครั้ง เพราะตัวที่ผ่านจะไม่พิมพ์อะไรเลย)
ส่วนที่ต้อง "ดูด้วยตา" เช่น แถบเมนูบังเนื้อหาไหม · เมนูหายบนจอเล็กไหม · เปลี่ยนหน้าแล้วโหลดใหม่ไหม — ผมเปิดเบราว์เซอร์จริงแล้ววัดตำแหน่งด้วย JavaScript (getBoundingClientRect() · getComputedStyle()) แทนการกะด้วยสายตา
ตัวอย่างที่ตั้งใจให้พัง (ลืม "use client" · ใช้ <a> แทน <Link>) ก็รันจริง เอาข้อความ error ตัวจริงมาโชว์
ทุกตัวเลขและทุกข้อความ error ในบทนี้ก็อปมาจากหน้าจอจริง

Layout คืออะไร — แบ่งหน้าเป็นส่วนที่ "อยู่ทุกหน้า" กับส่วนที่ "เปลี่ยนไปตามหน้า"

เปิดเว็บไหนก็ได้ที่คุณใช้ทุกวัน แล้วกดไปสักสามหน้า จะสังเกตว่าแถบบนสุดกับท้ายหน้าไม่เคยเปลี่ยน สิ่งที่เปลี่ยนมีแค่ตรงกลาง
สไลด์หน้า 35 วาดโครงนี้ไว้เป็นกล่องสี่กล่อง:

┌───────────────────────────────────────────────┐ │ Navigation Bar │ ← อยู่ทุกหน้า ├───────────┬───────────────────────────────────┤ │ │ │ │ Sidebar │ Contents │ ← Contents เปลี่ยนตามหน้า │ │ │ ├───────────┴───────────────────────────────────┤ │ Footer │ ← อยู่ทุกหน้า └───────────────────────────────────────────────┘

แล้วหน้า 36–37 เอาแบบร่างของ Wongnok มาวางทับให้ดูว่าแต่ละกล่องกลายเป็นอะไร: แถบบน = โลโก้ W Wongnok + ลิงก์ Home · Recipes + ช่องค้นหา + ปุ่ม Sign in · ตรงกลาง = page content · ท้ายหน้าสีดำ = คอลัมน์ Explore / Account / Legal
สังเกตว่า Wongnok ไม่มี Sidebar — กล่องสี่กล่องในหน้า 35 เป็นแม่แบบ ไม่ใช่ข้อบังคับ แอปส่วนใหญ่ใช้แค่สามส่วน

อีกเรื่องที่เห็นในหน้า 37 แต่สไลด์ไม่ได้พูด: ขอบซ้ายของโลโก้ ขอบซ้ายของเนื้อหา และขอบซ้ายของ footer ตรงกันเป๊ะ ทั้งที่พื้นหลังของแถบบนกับ footer ยาวเต็มจอ — นี่คืองานของ Container ที่เราจะเขียนในหัวข้อถัดไป

📰 เทียบกับของจริง

layout คือแบบฟอร์มหนังสือพิมพ์ — หัวหนังสือ (ชื่อฉบับ วันที่) อยู่บนสุดทุกหน้า เลขหน้ากับชื่อสำนักพิมพ์อยู่ล่างสุดทุกหน้า
กองบรรณาธิการไม่ต้องจัดหัวหนังสือใหม่ทุกหน้า เขาเปลี่ยนแค่ข่าวตรงกลาง · และข่าวทุกหน้าก็เริ่มที่ขอบกระดาษเดียวกัน ไม่มีหน้าไหนเบี้ยวไปทางซ้าย

ที่ไม่เหมือนกัน: หนังสือพิมพ์พิมพ์หัวหนังสือซ้ำลงกระดาษทุกแผ่นจริง ๆ
แต่ใน Next.js แถบบนถูกสร้างครั้งเดียวแล้วอยู่ตรงนั้นตลอด — ตอนเปลี่ยนหน้า มันไม่ถูกทิ้งแล้ววาดใหม่ (เดี๋ยวพิสูจน์ในหัวข้อ app/layout.tsx) · และหนังสือพิมพ์ไม่ต้องรองรับกระดาษที่กว้างตั้งแต่ 375 ถึง 1920 พิกเซล แต่เว็บต้อง

🗣 ศัพท์ของโครงหน้า
layout / app shell
โครงของหน้าที่ใช้ร่วมกันทุกหน้า (แถบบน · ท้ายหน้า · sidebar) · คำว่า app shell ใช้บ่อยในทีม frontend หมายถึง "เปลือก" ที่เหลือเมื่อตัดเนื้อหาของหน้าออก · ถาม senior ได้ว่า "ส่วนนี้ควรอยู่ใน app shell หรืออยู่ในหน้าเฉพาะ"
navbar / header · footer
แถบนำทางด้านบน กับส่วนท้ายหน้า · ใน HTML ใช้แท็ก <header> · <nav> · <footer> ซึ่ง screen reader ใช้กระโดดข้ามไปหาได้ · บอก AI ได้ว่า "use semantic header, nav and footer landmarks"
container
กล่องที่จำกัดความกว้างสูงสุดของเนื้อหาแล้ววางกลางจอ ทำให้ขอบซ้าย-ขวาของทุกส่วนตรงกัน · ประโยคที่ใช้ได้จริง: "ห่อเนื้อหาด้วย Container ตัวเดียวกับ Navbar ให้ขอบตรงกันหน่อย"

Container — component ที่สไลด์ใช้ แต่ไม่ได้เขียนให้ดู

โค้ด Navbar ในสไลด์หน้า 39 บรรทัด 18 เรียก <Container className="...">
ผมไล่ดูสไลด์หน้า 33–40 ครบทุกหน้าแล้ว ไม่มีหน้าไหนนิยาม Container ไว้ และ shadcn ที่ติดตั้งในโปรเจกต์ (button · card · badge · skeleton) ก็ไม่มีตัวนี้ — ถ้าก็อปโค้ดสไลด์ไปวางเฉย ๆ จะ build ไม่ผ่านเพราะหา Container ไม่เจอ

ข่าวดีคือมันเป็น component ที่ง่ายที่สุดในแอปทั้งแอป — <div> หนึ่งตัวที่มีสามหน้าที่:

// components/layout/Container.tsx  (ผมเขียนเอง — สไลด์ไม่ได้ให้มา)
import { cn } from "@/lib/utils";

export function Container({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8", className)}
      {...props}
    />
  );
}
classCSS จริงที่ Tailwind สร้าง (ก็อปจากไฟล์ CSS หลัง build)หน้าที่
max-w-7xlmax-width:var(--container-7xl) · และ --container-7xl:80remกว้างได้ไม่เกิน 80rem (1280px) · จอใหญ่กว่านั้นเนื้อหาจะไม่ยืดตาม
mx-automargin-inline:autoช่องว่างซ้าย-ขวาเท่ากัน = อยู่กลางจอ
px-4 sm:px-6 lg:px-8padding-inline:calc(var(--spacing) * 4) · --spacing:.25remเว้นขอบใน 1rem บนมือถือ แล้วค่อยกว้างขึ้นเมื่อจอใหญ่ขึ้น — ตัวหนังสือจะไม่ชิดขอบจอ

ทำไมรับ className แล้วส่งผ่าน cn()? เพราะคนเรียกต้องเติมของเพิ่มได้ — Navbar เติม flex h-16 items-center ... · footer เติม grid py-10
cn() รวม class สองชุดเข้าด้วยกันและตัดตัวที่ชนกันทิ้งให้เหลือตัวหลัง (ในโปรเจกต์นี้ lib/utils.ts มีบรรทัดเดียวคือ export { cn } from "cn" — shadcn ติดตั้งให้) · ผลรันจริงด้วย node ใส่ class ของ Container แล้วตามด้วย "px-8 flex h-16": "mx-auto w-full max-w-7xl sm:px-6 lg:px-8 px-8 flex h-16" — px-4 หายไป เหลือ px-8 ตัวหลัง · ส่วน ...props ทำให้ส่ง id · aria-* อะไรก็ได้ที่ <div> รับ (เรื่อง box model · padding · margin ทบทวนได้ที่ บท 60)

🖼 เทียบกับของจริง

Container คือแผ่นรองรูปในกรอบรูป (passe-partout) — ผนังจะกว้างแค่ไหนก็ตาม รูปถูกวางไว้กลางแผ่นรองเสมอ มีขอบขาวรอบ ๆ ไม่ชิดกรอบ
และถ้าคุณแขวนรูปสามรูปเรียงแนวตั้งด้วยแผ่นรองขนาดเดียวกัน ขอบซ้ายของทั้งสามรูปจะตรงกันเอง โดยไม่ต้องวัดทีละรูป

ที่ไม่เหมือนกัน: แผ่นรองรูปมีขนาดตายตัว แต่ Container หดได้ — จอแคบกว่า 1280px มันก็กว้างเท่าจอ (w-full) เหลือแค่ขอบใน · มันจำกัดแค่ "ใหญ่สุด" ไม่ได้บังคับ "เท่านี้เป๊ะ" · และมันไม่ได้ทาสีพื้นหลังให้ — พื้นหลังเต็มจอต้องใส่ที่กล่องแม่ชั้นนอก (อย่าง <header>) ไม่ใช่ที่ Container

🗣 ศัพท์ของ Container
max-width + margin-inline: auto (centering)
สูตรมาตรฐานของการวางกล่องกลางจอ: จำกัดความกว้างสูงสุด แล้วให้ขอบซ้าย-ขวาเป็น auto · บอก AI ได้ว่า "center the content with a max-w-7xl container and responsive horizontal padding"
className passthrough · ...props (prop spreading)
ออกแบบ component ให้คนเรียกเติม class และ attribute เพิ่มได้ โดยไม่ต้องแก้ตัว component · React.ComponentProps<"div"> คือ type ของทุก prop ที่ <div> รับ · ถาม senior ได้ว่า "component นี้ควรรับ className กับ ...props ต่อไปให้ element ข้างในไหม"
cn() (class merging)
ฟังก์ชันรวม class หลายชุด แล้วตัดตัวที่ชนกันให้เหลือตัวหลัง เช่น px-4 กับ px-8 เหลือ px-8 · เวลาเจอ class ที่ส่งไปแล้ว "ไม่ติด" ให้ถามว่า "ตรงนี้ผ่าน cn() หรือต่อ string ธรรมดา"

ถอดโค้ด Navbar จากสไลด์หน้า 39

นี่คือโค้ดตามสไลด์ทุกตัวอักษรตั้งแต่บรรทัด 12 ถึง 43 ส่วนที่สไลด์ตัดทิ้ง ผมเติมให้ครบพอ build ได้ และติดป้ายไว้ทุกจุดที่ผมเติมเอง:

// components/layout/Navbar.tsx
// ── บรรทัด 1–11 สไลด์ไม่ได้โชว์ · ผมเติมเท่าที่โค้ดข้างล่างต้องใช้ ──
import Link from "next/link";

import { Container } from "@/components/layout/Container";
import { cn } from "@/lib/utils";

const NAV_LINKS = [
  { href: "/", label: "Home", active: true },          // ← ผมเดาว่า active มาจากตรงนี้
  { href: "/recipes", label: "Recipes", active: false },
];
// ── จากนี้ไปตรงกับสไลด์ ──

function Navbar() {
  return (
    <header
      data-slot="navbar"
      className="fixed inset-x-0 top-0 z-50 border-b border-border-subtle bg-card"
    >
      <Container className="flex h-16 items-center justify-between gap-6">
        <div className="flex items-center gap-6">
          <Link href="/" className="flex shrink-0 items-center gap-2">
            <span className="wongnok-text-base flex size-8.5 items-center justify-center rounded-lg bg-linear-135 from-primary to-primary-hover font-bold text-primary-foreground">
              W
            </span>
            <span className="wongnok-text-h3 font-bold tracking-tight text-foreground">
              Wongnok
            </span>
          </Link>

          <nav
            aria-label="Main"
            className="hidden items-center gap-1 lg:flex"
          >
            {NAV_LINKS.map((link) => (
              <Link
                key={link.href}
                href={link.href}
                aria-current={link.active ? "page" : undefined}
                className={cn(
                  "wongnok-text-sm rounded-md px-3.5 py-2 font-medium transition-colors",
                  link.active
                    ? "text-primary"
                    : "text-secondary-foreground hover:bg-muted",
                )}
              >
                {link.label}
              </Link>
            ))}
          </nav>
        </div>
        {/* สไลด์ตัดจบที่บรรทัด 43 · ช่องค้นหากับปุ่ม Sign in (เห็นในภาพหน้า 36) ไม่ได้โชว์โค้ด */}
      </Container>
    </header>
  );
}

export default Navbar;

ผลตรวจจริงของไฟล์นี้ (ใส่ใน app/layout.tsx แล้ว — วิธีใส่อยู่หัวข้อท้าย ๆ):

$ npx next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /recipes

$ npx tsc --noEmit; echo "tsc rc=$?"
tsc rc=0
$ npx eslint .; echo "eslint rc=$?"
eslint rc=0
⚠️ build ผ่าน แต่ห้า class ในสไลด์ไม่มีผลอะไรเลยในโปรเจกต์ที่ติดตั้งมาเปล่า ๆ

ผมเปิดไฟล์ CSS ที่ next build สร้าง แล้วค้นทีละ class ที่ Navbar ใช้ — ผลจริง:

.fixed                    .fixed{position:fixed}
.lg\:flex                 .lg\:flex{display:flex}
.from-primary             .from-primary{--tw-gradient-from:var(--primary);...
.size-8\.5                .size-8\.5{width:calc(var(--spacing) * 8.5);...
.wongnok-text-base        NOT FOUND
.wongnok-text-sm          NOT FOUND
.wongnok-text-h3          NOT FOUND
.border-border-subtle     NOT FOUND
.to-primary-hover         NOT FOUND

ห้าตัวที่ NOT FOUND เป็นชื่อสีและขนาดตัวอักษรเฉพาะของ Wongnok (design token) ที่ต้องประกาศไว้ใน app/globals.css ก่อน — โปรเจกต์ที่ผมใช้ตรวจยังไม่มี จึงไม่มี CSS ถูกสร้างให้
วิธีประกาศอยู่ใน บท 81 — สีต้องเพิ่มสองชั้น (--primary-hover ใน :root และ --color-primary-hover ใน @theme inline) ส่วน wongnok-text-* ต้องประกาศด้วย @utility
Tailwind ไม่ฟ้องอะไรเลย ทั้งตอน build และตอน lint — class ที่มันไม่รู้จักแค่ถูกข้ามไปเงียบ ๆ (กับดักเดียวกับชื่อ class ที่สร้างจากตัวแปรใน บท 72)
ถ้าเส้นขอบใต้ Navbar ของคุณเป็นสีเทาธรรมดา หรือโลโก้ไม่มีไล่สี ให้เช็คตรงนี้ก่อน

กับดักที่ 1 — fixed top-0 แล้วหัวข้อแรกของหน้าหายไปใต้แถบเมนู

fixed สั่งให้กล่องหลุดออกจากลำดับการวางของหน้า แล้วไปติดอยู่กับหน้าต่างเบราว์เซอร์แทน
ปกติกล่องบนหน้าเว็บวางต่อกันลงมาเหมือนตัวต่อ — กล่องแรกกินที่เท่าไหร่ กล่องถัดไปก็เลื่อนลงเท่านั้น (ดูเรื่อง "กินที่" ใน บท 60)
แต่พอเป็น position: fixed กล่องนั้นไม่กินที่ในลำดับอีกต่อไป — กล่องถัดไปจึงขยับขึ้นมาแทนที่ราวกับว่าไม่มี Navbar อยู่ แล้ว Navbar ก็ลอยทับอยู่ข้างบน (z-50 คือบอกว่า "ลอยอยู่ชั้นบน")

พิสูจน์ด้วยหน้าจริง: หน้า / มีแค่ <h1>HOME PAGE</h1> · <main> ยังไม่ใส่ padding อะไร · เปิดที่จอกว้าง 1280px แล้ววัด:

// วัดในเบราว์เซอร์ด้วย getBoundingClientRect() · elementFromPoint()
{
  "innerWidth": 1280,
  "header": { "top": 0, "bottom": 65, "height": 65 },
  "h1":     { "top": 0, "bottom": 24, "left": 0 },
  "elementAtH1": "inside HEADER"
}

h1 เริ่มที่ 0 จบที่ 24 — อยู่ใต้ header ที่กินพื้นที่ 0 ถึง 65 ทั้งตัว
และ elementFromPoint (ถามเบราว์เซอร์ว่า "ถ้าคลิกตรงหัวข้อ จะโดนอะไร") ตอบว่าโดน header — ผู้ใช้มองไม่เห็นและคลิกไม่โดนหัวข้อแรกของทุกหน้า

วิธีแก้ที่ตรงที่สุดคือเว้นที่ด้านบนของเนื้อหาเท่ากับความสูงของ Navbar · Navbar สูง h-16 = 16 × 0.25rem = 4rem = 64px ก็เลยใส่ pt-16 ที่ <main>

// app/layout.tsx
<main className="pt-16">{children}</main>

// จากการวัดข้างบน: header สูง 65px (ไม่ใช่ 64)

ใส่ pt-16 แล้ว หัวข้อ h1 จะเริ่มที่พิกเซลที่เท่าไหร่ — และยังโดนบังอยู่ไหม?

ผลวัดจริง (ครั้งนี้วัดที่จอ 375px):

{
  "mainClass": "pt-16",
  "header": { "bottom": 65 },
  "h1":     { "top": 64, "bottom": 88 }
}

h1 เริ่มที่ 64 แต่ header จบที่ 65 — ยังโดนบังอยู่ 1 พิกเซล
1 พิกเซลนั้นมาจาก border-b ของ <header> — h-16 ใส่ไว้ที่ Container ข้างใน (64px) แล้วเส้นขอบล่างของ header มาต่อท้ายอีก 1px รวมเป็น 65
1 พิกเซลด้วยตาเปล่าแทบไม่เห็น แต่นี่คือเหตุผลที่ต้องวัด ไม่ใช่คิดเลขเอา — ถ้าคิดเลขอย่างเดียว เราจะมั่นใจว่า 64 พอดีทั้งที่ไม่พอดี

มีอีกทางที่ไม่ต้องคิดเลขเลย: เปลี่ยน fixed inset-x-0 top-0 เป็น sticky top-0 แล้วเอา pt-16 ออก · sticky ยังกินที่ในลำดับตามปกติ แต่พอเลื่อนหน้าลงมันจะติดขอบบนไว้ · ผลวัดจริงของแบบนี้: "headerPos": "sticky" · header จบที่ 65 · h1 เริ่มที่ 65 พอดี
ข้อสังเกต: แบบร่างในสไลด์หน้า 36 เขียนไว้ว่า "Scroll to see sticky shadow" — ตัวออกแบบเองก็คิดถึงแถบเมนูแบบ sticky แต่โค้ดหน้า 39 ใช้ fixed
ในบทนี้ผมคงไว้แบบสไลด์ (fixed + pt-16) เพื่อให้เทียบกับสไลด์ได้ตรง ๆ

กับดักที่ 2 — hidden lg:flex แล้วเมนูหายไปบนมือถือ

อ่าน class นี้จากซ้ายไปขวา: "ปกติซ่อน · พอจอกว้างถึง lg แล้วค่อยแสดงเป็น flex"
Tailwind คิดแบบ mobile-first — class ที่ไม่มีคำนำหน้าใช้กับทุกขนาดจอ ส่วน lg: แปลว่า "ตั้งแต่ขนาดนี้ขึ้นไป" · CSS จริงที่ Tailwind สร้างให้:

.hidden{display:none}
...
@media (min-width:64rem){ ... .lg\:flex{display:flex} ... }

64rem = 1024px · ผลวัดจริงบนเบราว์เซอร์:

ความกว้างจอgetComputedStyle(nav).displayผู้ใช้เห็น
1280px"flex"โลโก้ + Home + Recipes
375px"none"โลโก้อย่างเดียว · ไปหน้า Recipes ไม่ได้เลย

สไลด์หน้า 36 มีข้อความเล็ก ๆ ในแบบร่างว่า "resize to see mobile drawer" — แปลว่าตัวออกแบบมีเมนูแบบลิ้นชักไว้แทนบนมือถือ แต่โค้ดที่สไลด์หน้า 39 โชว์ไม่มีลิ้นชักนั้น · ถ้าคุณหยุดที่โค้ดในสไลด์ แอปของคุณใช้บนมือถือไม่ได้ ต้องรู้ไว้ว่านี่คือ "งานที่ยังค้าง" ไม่ใช่ "เสร็จแล้ว" (เรื่อง display ทบทวนได้ที่ บท 61)

กับดักที่ 3 — link.active มาจากไหน?

สไลด์บรรทัด 37 และ 40 ใช้ link.active ตัดสินว่าลิงก์ไหนเป็นสีหลักและได้ aria-current="page"
แต่บรรทัด 1–11 ที่ประกาศ NAV_LINKS ถูกตัดไป — สไลด์ไม่ได้บอกว่า active คำนวณจากอะไร
ผมเขียนแบบที่ตรงไปตรงมาที่สุดคือใส่ active: true ไว้ที่ Home ในอาร์เรย์ · build ผ่าน หน้าตาเหมือนสไลด์หน้า 40 (Home เป็นสีหลัก) · แล้วลองกดไปหน้า Recipes:

$ curl -s http://localhost:383/recipes | grep -o '<a aria-current="page"[^>]*>[A-Za-z]*</a>'
<a aria-current="page" class="wongnok-text-sm rounded-md px-3.5 py-2 font-medium transition-colors text-primary" href="/">Home</a>

อยู่หน้า Recipes แท้ ๆ แต่ Home ยังเป็นลิงก์ที่ "active" — เพราะ true ที่เขียนไว้ในอาร์เรย์ไม่รู้เรื่องว่าตอนนี้ URL คืออะไร
โปรแกรมอ่านหน้าจอ (screen reader) อ่าน aria-current ตัวนี้ จึงจะบอกผู้ใช้ผิดว่า Home คือหน้าปัจจุบัน

🗺 เทียบกับของจริง

aria-current="page" คือจุดแดง "คุณอยู่ที่นี่" บนแผนผังในห้าง
ถ้าใครพิมพ์แผนผังชุดเดียวแล้วแปะจุดแดงไว้ที่ประตูทางเข้า แล้วเอาไปติดทุกชั้น — ขึ้นไปชั้นห้าก็ยังเห็นว่า "คุณอยู่ที่ประตูทางเข้า" · นั่นคือ active: true ที่เขียนตายตัวไว้
แผนผังที่ถูกต้องต้องดูว่าตัวเองติดอยู่ชั้นไหนแล้วค่อยแปะจุด — ใน Next.js ตัวที่ "ดูว่าตอนนี้อยู่ URL ไหน" คือ usePathname()

ที่ไม่เหมือนกัน: แผนผังในห้างติดแล้วอยู่กับที่ ห้างรู้ล่วงหน้าว่าแต่ละแผ่นอยู่ชั้นไหน
แต่ Navbar เป็นแผ่นเดียวที่อยู่กับคุณตลอดทุกหน้า (มันอยู่ใน layout ไม่ถูกสร้างใหม่) มันจึงต้องคอยฟังว่า URL เปลี่ยนแล้ว — และ "คอยฟัง" คือสิ่งที่ต้องทำใน browser ไม่ใช่บน server ซึ่งนำไปสู่กับดักถัดไป

แก้ให้ถูกตาม URL จริง: ลบ active ออกจากอาร์เรย์ แล้วคำนวณจาก usePathname() แทน

// components/layout/Navbar.tsx — ไม่ได้แตะบรรทัดบนสุด
import Link from "next/link";
import { usePathname } from "next/navigation";
// ...
const NAV_LINKS = [
  { href: "/", label: "Home" },
  { href: "/recipes", label: "Recipes" },
];

function Navbar() {
  const pathname = usePathname();
  // ... ใน map: const active = pathname === link.href;

สั่ง npx next build — ผ่านไหม? ถ้าไม่ผ่าน error ชี้ไฟล์ไหน?

ไม่ผ่าน · output จริง (ตัดรหัสสีของ terminal ออก เนื้อความเหมือนเดิม):

▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 59ms

  Creating an optimized production build ...

> Build error occurred
Error: Turbopack build failed with 1 error:
./components/layout/Navbar.tsx:2:10
Error: You're importing a module that depends on `usePathname` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
    Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
  1 | import Link from "next/link";
> 2 | import { usePathname } from "next/navigation";
    |          ^^^^^^^^^^^
  3 |
  4 | import { Container } from "@/components/layout/Container";
  5 | import { cn } from "@/lib/utils";

Ecmascript file had an error

Import trace:
  Server Component:
    ./components/layout/Navbar.tsx
    ./app/layout.tsx

ข้อความเดียวกับที่เจอกับ useState ใน บท 78 — usePathname เป็น hook ที่ต้องรันใน browser เพราะมันต้อง "ฟัง" การเปลี่ยน URL
Import trace บอกว่า Navbar ถูกลากเข้ามาโดย app/layout.tsx — แต่ที่ต้องแก้คือบรรทัดบนของ trace (Navbar.tsx) ไม่ใช่ layout

ใส่ "use client"; เป็นบรรทัดแรกของ Navbar.tsx แล้วรันใหม่: next build ผ่าน · tsc rc=0 · eslint rc=0 · และ HTML จริง:

$ curl -s http://localhost:383/recipes | grep -o '<a aria-current="page"[^>]*>[A-Za-z]*</a>'
<a aria-current="page" class="... text-primary" href="/recipes">Recipes</a>
$ curl -s http://localhost:383/ | grep -o '<a aria-current="page"[^>]*>[A-Za-z]*</a>'
<a aria-current="page" class="... text-primary" href="/">Home</a>
⚠️ ทำไมไม่ใส่ "use client" ที่ app/layout.tsx ไปเลย

เพราะ "use client" ลากทุกอย่างที่ไฟล์นั้น import ไปฝั่ง client ด้วย — ใส่ที่ layout เท่ากับทั้งแอปกลายเป็น client component
และที่หนักกว่านั้น app/layout.tsx มี export const metadata อยู่ — ผมลองใส่ "use client" ที่ layout แล้ว build จริง ได้ error นี้:
./app/layout.tsx:18:14
Error: You are attempting to export "metadata" from a component marked with "use client", which is disallowed.
ถ้าอยากให้ส่งไป browser น้อยที่สุด ขั้นต่อไปคือแยกเฉพาะ <nav> ออกเป็น NavLinks.tsx ที่ติด "use client" แล้วให้ Navbar ที่เหลือเป็น server เหมือนเดิม

🗣 ศัพท์ของลิงก์ที่ active
active link · aria-current="page"
ลิงก์ที่ชี้มาหน้าปัจจุบัน ต้องต่างทั้งสี (คนที่มองเห็น) และ aria-current="page" (คนใช้ screen reader) · บอก AI ได้ว่า "highlight the active nav link using usePathname and set aria-current"
usePathname()
hook ของ next/navigation ที่คืน path ของ URL ปัจจุบัน เช่น "/recipes" · เป็น hook จึงใช้ได้เฉพาะไฟล์ที่มี "use client" · ถาม senior ได้ว่า "ตัวเช็ค active ควรเทียบ path ตรง ๆ หรือใช้ startsWith สำหรับหน้าย่อยอย่าง /recipes/123"
hardcoded value (ค่าที่เขียนตายตัว)
ค่าที่เขียนลงโค้ดตรง ๆ แทนที่จะคำนวณจากสถานการณ์จริง — active: true คือตัวอย่าง · ถูกเฉพาะกรณีที่คนเขียนนึกถึงตอนเขียน · ประโยคที่ใช้ได้จริง: "active state ตรงนี้ hardcode ไว้ ต้องผูกกับ route จริง"

กับดักที่ 4 — <Link> กับ <a> ออกมาเป็น HTML เหมือนกันเป๊ะ แล้วต่างกันตรงไหน

ดู HTML ที่ server ส่งมา ลิงก์ Home ออกมาเป็น <a ... href="/">Home</a> ธรรมดา ไม่มีอะไรบอกว่ามาจาก <Link> เลย · ความต่างอยู่ที่ตอนกด

ทดลอง: ก่อนกด ผมแปะตัวแปรไว้ในหน้าต่างเบราว์เซอร์ window.__marker = 'still-here' · ถ้าเบราว์เซอร์โหลดหน้าใหม่ทั้งหน้า JavaScript ทุกอย่างถูกล้าง ตัวแปรนี้จะหายไป

กด <Link href="/recipes"> ใน Navbar

{
  "url": "/recipes",
  "h1": "RECIPES PAGE",
  "marker": "still-here"
}

URL เปลี่ยน เนื้อหาเปลี่ยน แต่ตัวแปรยังอยู่ = ไม่ได้โหลดหน้าใหม่

กด <a href="/"> ธรรมดาในหน้า Recipes

{
  "url": "/",
  "h1": "HOME PAGE",
  "marker": "GONE (page reloaded)"
}

ได้หน้าที่ถูกต้องเหมือนกัน แต่ตัวแปรหาย = เบราว์เซอร์ทิ้งทั้งหน้าแล้วโหลดใหม่หมด

<Link> ดักการคลิกไว้ แล้วให้ Next.js ไปขอเฉพาะส่วนของหน้าที่เปลี่ยนมาเปลี่ยนแทน — Navbar · footer · state ที่อยู่ใน layout ไม่ถูกแตะ
<a> ธรรมดาปล่อยให้เบราว์เซอร์ทำแบบดั้งเดิม: ขอ HTML ใหม่ทั้งหน้า โหลด JavaScript ใหม่ทั้งหมด · state ทุกตัวในแอปกลับไปเป็นค่าเริ่มต้น (เช่น ถ้าช่องค้นหาใน Navbar เก็บคำที่พิมพ์ไว้ใน useState คำนั้นก็หาย)

และมีตัวที่จับให้ด้วย — eslint ของ create-next-app ถือว่าใช้ <a> ไปหน้าในแอปเป็น error (ไฟล์ทดลองคือ app/recipes/page.tsx ที่บรรทัด 5 มี <a id="plain" href="/">plain a</a>):

$ npx eslint app/recipes/page.tsx

# eslint พิมพ์ path เต็มของไฟล์ในบรรทัดนี้ ผมย่อเหลือส่วนท้าย
.../app/recipes/page.tsx
  5:7  error  Do not use an `<a>` element to navigate to `/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages  @next/next/no-html-link-for-pages

✖ 1 problem (1 error, 0 warnings)

(next build ผ่านนะ — ถ้าคุณรันแค่ build จะไม่มีวันรู้ · ลิงก์ไปเว็บข้างนอกใช้ <a> ได้ปกติ)

📺 เทียบกับของจริง

<Link> คือกดรีโมตเปลี่ยนช่อง — ทีวีเครื่องเดิม เปิดค้างอยู่ ความสว่าง ระดับเสียง ที่คุณตั้งไว้ยังอยู่ครบ เปลี่ยนแค่ภาพในจอ
<a> ธรรมดาคือถอดปลั๊กแล้วเสียบใหม่ทุกครั้งที่อยากดูช่องอื่น — ได้ช่องที่ต้องการเหมือนกัน แต่ต้องรอบูตเครื่อง และค่าที่ตั้งไว้กลับเป็นค่าโรงงาน

ที่ไม่เหมือนกัน: ทีวีมีช่องให้เลือกจำกัดและทุกช่องส่งมาพร้อมกันอยู่แล้ว
แต่เนื้อหาของหน้าใหม่ใน Next.js ยังต้องมาจาก server แค่มาเฉพาะส่วนที่เปลี่ยน — และตามเอกสารของ Next.js <Link> ยังขอไว้ล่วงหน้าได้ (prefetch) เพื่อให้กดแล้วเปลี่ยนทันที ซึ่งรีโมตทีวีไม่ได้ทำ

ใส่ Navbar ลงใน app/layout.tsx — สไลด์หน้า 40

สไลด์หน้า 40 เขียนไว้ประโยคเดียว: Integrate into /app/layout.tsx file to setup root layout พร้อมภาพผลลัพธ์ แต่ไม่ได้โชว์โค้ด · นี่คือ app/layout.tsx ที่ผมใช้ ส่วนที่เพิ่มจากไฟล์ที่ create-next-app สร้างให้มีแค่สามบรรทัดที่มีคอมเมนต์:

// app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Navbar from "@/components/layout/Navbar";   // ← เพิ่ม
import "./globals.css";

// ... geistSans · geistMono · metadata เหมือนเดิมทุกตัวอักษร ...

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html
      lang="en"
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col">
        <Navbar />                                     {/* ← เพิ่ม */}
        <main className="pt-16">{children}</main>          {/* ← เพิ่ม: เว้นที่ให้ Navbar */}
      </body>
    </html>
  );
}

LayoutProps<"/"> ไม่ได้ import มาจากไหน — Next.js 16 สร้าง type นี้ให้เองตอนรัน next dev หรือ next build · ถ้าคุณ clone โปรเจกต์มาใหม่แล้วรัน tsc ก่อน build ครั้งแรก จะเจอแบบนี้จริง:
app/layout.tsx(21,50): error TS2304: Cannot find name 'LayoutProps'.
รัน npx next build หนึ่งรอบแล้วรัน tsc --noEmit ใหม่ ได้ rc=0 · ไม่ต้องแก้โค้ด

บท 70 พิสูจน์ไว้แล้วด้วยปุ่มนับเลขว่า layout ไม่ถูกวาดใหม่ตอนเปลี่ยนหน้า · ครั้งนี้ผมเช็คกับ Navbar ตัวจริงอีกรอบด้วยวิธีที่ตรงกว่า — เก็บ element <header> ไว้ในตัวแปร กดลิงก์ Recipes แล้วเทียบว่ายังเป็นตัวเดิมไหม:

// Navbar เวอร์ชัน usePathname · เริ่มที่หน้า /
{
  "before": "Home",              // aria-current อยู่ที่ Home
  "after": "Recipes",            // กดแล้วย้ายไป Recipes เอง
  "url": "/recipes",
  "marker": "still-here",
  "sameHeaderNode": true          // <header> คือ element ตัวเดิม ไม่ได้สร้างใหม่
}

ผลนี้บอกสองอย่างพร้อมกัน: Navbar อยู่ที่เดิม (element ตัวเดิม) แต่ อัปเดตตัวเองได้ (aria-current ย้ายตาม URL) — เพราะ usePathname() คืนค่าใหม่เมื่อ URL เปลี่ยน แล้ว React แก้เฉพาะส่วนที่ต่างไปบน element ตัวเดิม
นี่คือเหตุผลที่ Navbar ควรอยู่ใน layout ไม่ใช่ก็อปไปวางในทุก page.tsx — ตารางใน บท 70 บอกไว้แล้วว่า page "ถูกแทนที่ด้วยหน้าใหม่" ตอนเปลี่ยนหน้า ของที่อยู่ใน page ก็ถูกแทนที่ไปด้วย

🗣 ศัพท์ของการเปลี่ยนหน้าและการวางตำแหน่ง
client-side navigation / soft navigation
เปลี่ยนหน้าโดยไม่ให้เบราว์เซอร์โหลดใหม่ทั้งหน้า — สิ่งที่ <Link> ทำ · ตรงข้ามคือ hard navigation (full page reload) ที่ <a> ธรรมดาทำ · ถาม senior ได้ว่า "ตรงนี้ state หายตอนเปลี่ยนหน้า เป็นเพราะ hard navigation หรือเปล่า"
prefetch
การที่ <Link> ไปขอข้อมูลของหน้าปลายทางไว้ล่วงหน้าก่อนผู้ใช้กด เพื่อให้เปลี่ยนหน้าได้ทันที · ปรับได้ด้วย prop prefetch (มีในหัวข้อ Reference ของเอกสาร Link) · ถาม senior ได้ว่า "เมนูนี้มีลิงก์เยอะ ควรปิด prefetch บางตัวไหม"
fixed vs sticky · breakpoint
fixed หลุดจากลำดับหน้า ต้องเว้นที่ให้เอง · sticky ยังกินที่แต่ติดขอบตอนเลื่อน · breakpoint คือความกว้างจอที่ class อย่าง lg: เริ่มทำงาน (lg = 64rem) · ประโยคที่ใช้ได้จริง: "navbar เป็น fixed แล้วบังหัวข้อ เปลี่ยนเป็น sticky ได้ไหม" · "บน breakpoint ต่ำกว่า lg เมนูหาย ต้องทำ mobile drawer เพิ่ม"

ภารกิจ — ปิดโครงหน้าให้ครบด้วย Footer

🎯 ภารกิจ: Footer ของ Wongnok

สไลด์หน้า 36–37 มี footer สีดำอยู่ในแบบร่าง แต่ไม่มีโค้ด · ลองเขียนเอง:

  1. สร้าง components/layout/Footer.tsx ที่มีชื่อ Wongnok + ข้อความ Share your recipe, inspire a meal. + คอลัมน์ลิงก์อย่างน้อยสองคอลัมน์ (Explore · Account) สร้างจากอาร์เรย์ + .map() แบบเดียวกับ NAV_LINKS
  2. ห่อเนื้อหาข้างในด้วย Container ตัวเดียวกับ Navbar — ขอบซ้ายต้องตรงกับโลโก้
  3. ใส่ <Footer /> ต่อจาก <main> ใน app/layout.tsx
  4. หน้าที่มีเนื้อหาสั้น ๆ footer ต้องไปอยู่ล่างสุดของจอ ไม่ใช่ลอยอยู่กลางจอ
  5. ตอบให้ได้ว่า Footer ต้องมี "use client" ไหม · แล้วรัน npx next build · npx tsc --noEmit · npx eslint . ให้ผ่านทั้งสาม
// components/layout/Footer.tsx
import Link from "next/link";

import { Container } from "@/components/layout/Container";

const FOOTER_COLUMNS = [
  {
    title: "Explore",
    links: [
      { href: "/recipes", label: "Recipes" },
      { href: "/favorites", label: "Favorites" },
      { href: "/recipes/create", label: "Create Recipe" },
    ],
  },
  {
    title: "Account",
    links: [
      { href: "/sign-in", label: "Sign in" },
      { href: "/profile", label: "Profile" },
      { href: "/my-recipes", label: "My Recipes" },
    ],
  },
];

function Footer() {
  return (
    <footer data-slot="footer" className="mt-auto bg-zinc-900 text-zinc-300">
      <Container className="grid gap-8 py-10 lg:grid-cols-3">
        <div>
          <p className="font-bold text-white">Wongnok</p>
          <p className="text-sm">Share your recipe, inspire a meal.</p>
        </div>

        {FOOTER_COLUMNS.map((column) => (
          <div key={column.title}>
            <p className="text-xs font-bold uppercase text-white">{column.title}</p>
            <ul className="mt-3 space-y-2 text-sm">
              {column.links.map((link) => (
                <li key={link.href}>
                  <Link href={link.href} className="hover:text-white">
                    {link.label}
                  </Link>
                </li>
              ))}
            </ul>
          </div>
        ))}
      </Container>
    </footer>
  );
}

export default Footer;
// app/layout.tsx — ใน <body>
<Navbar />
<main className="pt-16">{children}</main>
<Footer />
  • ไม่ต้องมี "use client" — Footer ไม่มี hook สักตัว ไม่ต้องรู้ว่าอยู่หน้าไหน ปล่อยเป็น Server Component ได้ · ใช้ <Link> ใน Server Component ได้ปกติ
  • ข้อ 4 แก้ด้วย mt-auto คำเดียว — เพราะ <body> ที่ create-next-app สร้างให้เป็น min-h-full flex flex-col อยู่แล้ว (สูงอย่างน้อยเต็มจอ + เรียงลูกเป็นแนวตั้ง) · margin-top: auto ในกล่อง flex คือ "กินที่ว่างที่เหลือทั้งหมดไว้ด้านบนตัวเอง" footer เลยถูกดันลงล่างสุด

ผลตรวจจริง:

$ npx next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /recipes

$ npx tsc --noEmit; echo "tsc rc=$?"
tsc rc=0
$ npx eslint .; echo "eslint rc=$?"
eslint rc=0

# วัดหน้า /recipes (มีแค่ h1 บรรทัดเดียว) บนจอ 1280×800
มี mt-auto   →  footer { top: 616, bottom: 800 }   innerHeight: 800
เอา mt-auto ออก →  footer { top: 88,  bottom: 272 }

ไม่มี mt-auto footer มาต่อท้าย h1 ทันที (เริ่มที่ 88 = 64 ของ pt-16 + 24 ของ h1) แล้วครึ่งล่างของจอว่างเปล่า · มี mt-auto ขอบล่างของ footer ชนขอบจอพอดีที่ 800
ถ้าของคุณยังลอยอยู่กลางจอ เช็คว่า <body> ยังมี flex flex-col อยู่ไหม — mt-auto ดันได้เฉพาะในกล่อง flex

ลองตอบดู

ใช้โค้ด Navbar แบบ NAV_LINKS ที่เขียน active: true ไว้ที่ Home · ผู้ใช้กดไปหน้า /recipes — ลิงก์ไหนได้ aria-current="page"?

ตัวเลือกที่ 3 ถูก · ผล curl หน้า /recipes จริงได้ <a aria-current="page" ... href="/">Home</a>
ตัวเลือกที่ 1 ผิดเพราะ <Link> ไม่ได้ใส่ aria-current ให้เอง — คุณต้องคำนวณเองด้วย usePathname() (เอกสาร Next.js มีตัวอย่างหัวข้อ Checking active links ไว้แบบนี้พอดี)
ตัวเลือกที่ 2 และ 4 ผิดเพราะ active เป็นแค่ค่าคงที่ในอาร์เรย์ ไม่มีใครล้างหรือสะสมมัน — มันเป็น true ที่ Home ตลอดไป
จุดที่ต้องจำ: สิ่งที่ขึ้นกับ URL ต้องคำนวณจาก URL — เขียนตายตัวไว้จะถูกแค่หน้าเดียว

หน้า Recipes มีช่องค้นหาที่ผู้ใช้พิมพ์ค้างไว้ในตัวแปร state ที่อยู่ใน layout · ถ้าลิงก์ใน Navbar เป็น <a href> ธรรมดาแทน <Link> กดไปหน้าอื่นแล้วจะเกิดอะไร?

ตัวเลือกที่ 1 ถูก · ในการทดลอง ตัวแปร window.__marker หายหลังกด <a> ("GONE (page reloaded)") แต่ยังอยู่หลังกด <Link> ("still-here")
ตัวเลือกที่ 2 ผิดเพราะ next build ผ่าน — ตัวที่ฟ้องคือ eslint (@next/next/no-html-link-for-pages)
ตัวเลือกที่ 3 ผิดเพราะ HTML เหมือนกันจริง แต่พฤติกรรมตอนคลิกต่างกัน · ตัวเลือกที่ 4 ผิดเพราะ <a> ยังพาไปหน้าที่ถูกต้องได้ปกติ แค่ช้ากว่าและล้าง state
จุดที่ต้องจำ: ลิงก์ไปหน้าในแอปเดียวกันใช้ <Link> เสมอ · และรัน eslint ด้วย ไม่ใช่แค่ next build

<nav className="hidden items-center gap-1 lg:flex"> · เปิดบน iPhone ที่จอกว้าง 375px — ผู้ใช้เห็นอะไร?

ตัวเลือกที่ 4 ถูก · ผลวัดจริงที่ 375px: getComputedStyle(nav).display = "none" และ CSS ที่ Tailwind สร้างคือ @media (min-width:64rem){ .lg\:flex{display:flex} }
ตัวเลือกที่ 2 ผิดเพราะลำดับคำใน className ไม่ได้ตัดสินอะไร — ที่ตัดสินคือ CSS ที่ Tailwind สร้าง ซึ่ง .lg\:flex อยู่ใน @media (min-width:64rem) ทำงานเฉพาะจอกว้างเท่านั้น · ตัวเลือกที่ 1 ผิดเพราะ display:none คือไม่แสดงเลย ไม่ได้เปลี่ยนทิศ · ตัวเลือกที่ 3 ผิดเพราะไม่มีอะไรสร้างลิ้นชักให้ — แบบร่างหน้า 36 พูดถึง mobile drawer แต่ต้องเขียนเอง
จุดที่ต้องจำ: Tailwind เป็น mobile-first — class ไม่มีคำนำหน้าคือค่าของจอเล็ก · ซ่อนอะไรบนมือถือ ต้องมีทางอื่นให้ผู้ใช้ไปถึงมันเสมอ

เปลี่ยน Navbar ให้ใช้ usePathname() แล้ว build พังว่า You're importing a module that depends on `usePathname` into a React Server Component module — ควรใส่ "use client" ที่ไหน?

ตัวเลือกที่ 2 ถูก · error ชี้ ./components/layout/Navbar.tsx:2:10 ตรงคำว่า usePathname ใส่ "use client"; บรรทัดแรกของไฟล์นั้นแล้ว build ผ่าน · tsc rc=0 · eslint rc=0
ตัวเลือกที่ 1 ผิดเพราะจะลากทั้งแอปไปฝั่ง client และ build พังอีกแบบ — ผลรันจริง: You are attempting to export "metadata" from a component marked with "use client", which is disallowed. · ตัวเลือกที่ 3 ผิดเพราะ error บอกให้ "mark the file" — "use client" ติดที่ทั้งไฟล์ ต้องอยู่บนสุดก่อน import ทุกตัว ไม่ใช่ติดทีละบรรทัดในฟังก์ชัน · ตัวเลือกที่ 4 ผิดเพราะ Navbar ไม่ได้ถูก import ใน page ใดเลย มันอยู่ใน layout
จุดที่ต้องจำ: อ่าน Import trace จากบนลงล่าง — บรรทัดบนคือไฟล์ที่ต้องแก้ บรรทัดล่างคือคนที่เรียกมันมา

ถามกลับได้เลย

ถ้าเปิดแอปแล้ว Navbar ของคุณหน้าตาไม่เหมือนสไลด์หน้า 40 — สีไม่ออก เส้นขอบหาย โลโก้ไม่ไล่สี — เอา app/globals.css มาคุยกัน ส่วนใหญ่เป็นเพราะ token ของ Wongnok ยังไม่ได้ประกาศ
ถ้าอยากทำ mobile drawer ต่อ หรือสงสัยว่าควรแยก NavLinks ออกเป็น client component ตัวเล็กไหม ก็ถามได้ · หรือถ้าเจอ error ตอน build ก็เอาข้อความทั้งก้อนมา โดยเฉพาะบรรทัด Import trace

อ้างอิง