🧰 ของที่ใช้ตรวจในบทนี้
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 วาดโครงนี้ไว้เป็นกล่องสี่กล่อง:
แล้วหน้า 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}
/>
);
}
| class | CSS จริงที่ Tailwind สร้าง (ก็อปจากไฟล์ CSS หลัง build) | หน้าที่ |
|---|---|---|
max-w-7xl | max-width:var(--container-7xl) · และ --container-7xl:80rem | กว้างได้ไม่เกิน 80rem (1280px) · จอใหญ่กว่านั้นเนื้อหาจะไม่ยืดตาม |
mx-auto | margin-inline:auto | ช่องว่างซ้าย-ขวาเท่ากัน = อยู่กลางจอ |
px-4 sm:px-6 lg:px-8 | padding-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
- 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
ผมเปิดไฟล์ 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 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>ไปขอข้อมูลของหน้าปลายทางไว้ล่วงหน้าก่อนผู้ใช้กด เพื่อให้เปลี่ยนหน้าได้ทันที · ปรับได้ด้วย propprefetch(มีในหัวข้อ 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 สีดำอยู่ในแบบร่าง แต่ไม่มีโค้ด · ลองเขียนเอง:
- สร้าง
components/layout/Footer.tsxที่มีชื่อ Wongnok + ข้อความ Share your recipe, inspire a meal. + คอลัมน์ลิงก์อย่างน้อยสองคอลัมน์ (Explore · Account) สร้างจากอาร์เรย์ +.map()แบบเดียวกับNAV_LINKS - ห่อเนื้อหาข้างในด้วย
Containerตัวเดียวกับ Navbar — ขอบซ้ายต้องตรงกับโลโก้ - ใส่
<Footer />ต่อจาก<main>ในapp/layout.tsx - หน้าที่มีเนื้อหาสั้น ๆ footer ต้องไปอยู่ล่างสุดของจอ ไม่ใช่ลอยอยู่กลางจอ
- ตอบให้ได้ว่า 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
อ้างอิง
- Next.js — Link Component —
อ่านหัวข้อ Checking active links ซึ่งเป็นตัวอย่าง
usePathnameแบบเดียวกับที่บทนี้ใช้แก้link.active· แล้วอ่าน Scroll offset with sticky headers ต่อ ถ้าคุณใช้ Navbar แบบ fixed แล้วกดลิงก์ไป#idแล้วหัวข้อโดนบัง - Next.js —
usePathname— อ่านสองย่อหน้าแรกให้จบ — ประโยคแรกบอกตรง ๆ ว่าเป็น Client Component hook และย่อหน้าถัดมาอธิบายว่า component ที่ใช้มันยังถูกเรนเดอร์เป็น HTML ตอนโหลดหน้าแรกอยู่ดี (ตรงกับที่curlในบทนี้ยังเห็นaria-currentใน HTML) - Tailwind CSS — Responsive design —
อ่านหัวข้อ Working mobile-first และตาราง breakpoint ในหัวข้อ Overview
(จะเห็น
lg= 64rem ตรงกับ CSS ที่บทนี้ก็อปมา) - MDN —
position— อ่านคำอธิบายค่าfixedกับstickyประโยคที่บอกว่า fixed "removed from the normal document flow" คือต้นเหตุของกับดักที่ 1 - MDN —
aria-current— อ่านคำอธิบายค่าpageที่ใช้กับลิงก์ในเมนูนำทาง · หลักคือเมื่อลิงก์ของหน้าปัจจุบันหน้าตาต่างจากลิงก์อื่น (เช่นเป็นสีหลัก) ต้องมีaria-currentบอกด้วย คนที่มองไม่เห็นสีจะได้รู้เหมือนกัน - Ramotion — Web Design Layout (ลิงก์ในสไลด์หน้า 35) —
อ่านหัวข้อ Core Elements of Web Design Layout ซึ่งไล่องค์ประกอบไว้หกอย่าง
(Top header · Navigation · Header · Content area · Sidebar · Footer) — กล่องสี่กล่องในสไลด์ยุบมาจากรายการนี้ · แล้วข้ามไป Responsiveness กับ Mobile-Friendliness
ซึ่งเป็นเรื่องที่เมนู
hidden lg:flexยังทำไม่ครบ