ข้ามไปยังเนื้อหา

รีวิวโค้ดชุดที่ 3: แคตตาล็อกสินค้าอีคอมเมิร์ซที่มีรูปภาพ, metadata, และตะกร้า

โจทย์ที่ให้ AI คือ: “สร้างแคตตาล็อกสินค้าที่มี grid, หน้าสินค้าแต่ละชิ้น, และปุ่ม Add to cart” โปรเจกต์นี้เปิด cacheComponents: true เช่นกัน และหน้าสินค้าควรถูก prerender ตอน build — เว็บอีคอมเมิร์ซจริงรับไม่ไหวถ้าต้องมี server round trip ต่อผู้เยี่ยมชมหนึ่งคนสำหรับหน้าที่แทบไม่เปลี่ยนแปลง อ่านตามลำดับการอ่านเดียวกับสองรีวิวแรก: boundary ก่อน แล้วค่อย routing แล้วค่อย caching model แล้วค่อยรายละเอียดด้าน optimization ที่เว็บช็อปปิ้งอยู่หรือตายเพราะมัน โค้ดชุดนี้มี bug จริง 6 จุด และ next build จับได้ตรงๆ ถึง 4 จุด

// next.config.ts — as generated
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
// lib/products.ts — as generated
export type Product = { id: string; name: string; price: number; image: string }
const products: Product[] = [
{ id: '1', name: 'Desk lamp', price: 2900, image: '/product.png' },
{ id: '2', name: 'Ceramic mug', price: 1200, image: '/product.png' },
{ id: '3', name: 'Notebook', price: 800, image: '/product.png' },
]
export async function listProducts() {
return products
}
export async function getProduct(id: string) {
return products.find((p) => p.id === id) ?? null
}
// app/products/page.tsx — as generated
import Image from 'next/image'
import { listProducts } from '@/lib/products'
export default async function ProductsPage() {
const products = await listProducts()
return (
<main>
<h1>Products</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{products.map((p) => (
<a key={p.id} href={`/products/${p.id}`}>
<div style={{ position: 'relative', height: 200 }}>
<Image src={p.image} alt={p.name} fill style={{ objectFit: 'cover' }} />
</div>
<p>{p.name}</p>
</a>
))}
</div>
</main>
)
}
// app/products/[id]/page.tsx — as generated
import Image from 'next/image'
import type { Metadata } from 'next'
import { getProduct } from '@/lib/products'
import { addToCart } from './actions'
import { RecentlyViewed } from './recently-viewed'
export const runtime = 'edge'
export async function generateStaticParams() {
return []
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const product = await getProduct(id)
return {
title: product?.name ?? 'Product',
viewport: 'width=device-width, initial-scale=1',
openGraph: { images: [product?.image ?? '/product.png'] },
}
}
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await getProduct(id)
if (!product) return <p>Not found</p>
return (
<main>
<h1>{product.name}</h1>
<div style={{ position: 'relative', width: 400, height: 400 }}>
<Image src={product.image} alt={product.name} fill style={{ objectFit: 'cover' }} />
</div>
<p>${(product.price / 100).toFixed(2)}</p>
<form action={addToCart.bind(null, product.id)}>
<button type="submit">Add to cart</button>
</form>
<RecentlyViewed />
</main>
)
}
// app/products/[id]/actions.ts — as generated
'use server'
export async function addToCart(productId: string) {
console.log('added to cart', productId)
}
// app/products/[id]/recently-viewed.tsx — as generated
import dynamic from 'next/dynamic'
const RecentlyViewedWidget = dynamic(() => import('./recently-viewed-widget'), { ssr: false })
export function RecentlyViewed() {
return <RecentlyViewedWidget />
}
// app/products/[id]/recently-viewed-widget.tsx — as generated
'use client'
export default function RecentlyViewedWidget() {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem('recent') : null
return <p>Recently viewed: {raw ?? 'none yet'}</p>
}

recently-viewed.tsx ไม่มี directive 'use client' เลย — มันคือ Server Component — และมันเรียก dynamic(() => import(...), { ssr: false })

🐛 หา bug ในโค้ดนี้

import dynamic from 'next/dynamic'
const RecentlyViewedWidget = dynamic(() => import('./recently-viewed-widget'), { ssr: false })
export function RecentlyViewed() {
return <RecentlyViewedWidget />
}
เฉลย

ssr: false แปลว่า “ห้าม render สิ่งนี้บนเซิร์ฟเวอร์เลย” — เป็นคำขอที่มีแค่ Client Component เท่านั้นที่ทำได้กับตัวเอง เพราะ Server Component ไม่มีทางเลือกฝั่ง client ให้ fallback กลับไปได้ build ไฟล์นี้เป๊ะๆ ล้มเหลวทันที:

Error: Turbopack build failed with 1 error:
./app/products/[id]/recently-viewed.tsx:4:20
Error: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.

วิธีแก้ที่ข้อความ error ของ Next.js เองบอกไว้ก็คือคำตอบที่ถูกต้อง: เพิ่ม 'use client' ไว้บนสุดของไฟล์นี้ ตัวเลือก ssr: false ของ next/dynamic จะกลายเป็นคำขอที่ framework ทำตามได้จริง เพราะทั้งโมดูลเป็น client-only อยู่แล้ว

grid สินค้าลิงก์ไปแต่ละหน้าสินค้าด้วย <a href={...}> แทนที่จะเป็น <Link>

🐛 หา bug ในโค้ดนี้

<a key={p.id} href={`/products/${p.id}`}>
<div style={{ position: 'relative', height: 200 }}>
<Image src={p.image} alt={p.name} fill style={{ objectFit: 'cover' }} />
</div>
<p>{p.name}</p>
</a>
เฉลย

<a> ธรรมดาก็ยัง navigate ได้ — นี่คือเหตุผลที่ bug นี้พลาดง่ายมากตอนดูผ่านๆ build แล้วคลิกใน browser จริง ความต่างจะปรากฏใน network log ไม่ใช่ที่ว่าหน้าเว็บทำงานหรือไม่ คลิก <Link href="/products/1"> ในหน้าเดียวกันจะได้ request เล็กๆ สองอันที่มี query parameter _rsc และไม่มีอะไรอื่นเลย:

GET /products/1?_rsc=rq0KtoLEX-G_daAz -> 200
GET /products/1?_rsc=Uj6tDUbDhWjKIlIB -> 200

คลิก <a href="/products/2"> ธรรมดาแทน จะได้ full document request ที่ไม่มี parameter _rsc เลย ตามด้วยทุก static asset — font, CSS, ทุก JS chunk — โหลดใหม่ทั้งหมดตั้งแต่ต้น เหมือนกับตอนโหลดหน้าครั้งแรกเป๊ะ:

GET /products/2 -> 200 (a full navigation, not an RSC fetch)
GET /_next/static/media/797e433ab948586e-s.p.0r6juujl39pe6.woff2 -> 200
GET /_next/static/chunks/3n8-9sbopjxwd.css -> 200
GET /_next/static/chunks/3s6nzrbk-8mnv.js -> 200
... (every other static chunk, re-downloaded)

วิธีแก้คือ <Link href={...}> จาก next/link — หน้าตาและการทำงานเหมือนกันทุกประการสำหรับผู้ใช้ที่ไม่เคยเปิด network panel เลย แต่มันข้ามการ reload หน้าทั้งหมดและการโหลด asset ที่ browser มีอยู่แล้วซ้ำ

generateStaticParams บนหน้าสินค้าคืนค่า []

🐛 หา bug ในโค้ดนี้

export async function generateStaticParams() {
return []
}
เฉลย

ภายใต้ Cache Components generateStaticParams ที่ว่างเปล่าไม่ใช่แค่ “ไม่มีหน้าไหนถูก prerender” — มันคือ build error ตรงๆ เพราะ framework ไม่สามารถทำ build-time validation ได้อีกต่อไปว่าไม่มีอะไร dynamic รั่วผ่านมาสำหรับชุด param ที่มันไม่เคยเห็นเลย:

Error: When using Cache Components, all `generateStaticParams` functions must return at least one result. This is to ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime error.
Learn more: https://nextjs.org/docs/messages/empty-generate-static-params

วิธีแก้คือคืนค่าชุด param จริงๆ — listProducts() มีอยู่แล้วในโค้ดชุดนี้เองสำหรับหน้า grid generateStaticParams แค่ต้อง map มันเป็น object { id } เหมือนที่บทstatic และ dynamic renderingแสดงไว้สำหรับกรณีทั่วไป

หน้าสินค้ายัง export runtime = 'edge' ด้วย

🐛 หา bug ในโค้ดนี้

export const runtime = 'edge'
เฉลย

นี่คือความล้มเหลวหมวดเดียวกับที่บทroute segment configพิสูจน์ไว้สำหรับ route segment config export ทุกตัว — runtime ถูกปฏิเสธภายใต้ Cache Components ไม่ว่าค่าจะเป็นอะไร ไม่ใช่แค่ 'edge' เพราะ Cache Components ต้องการ Node.js runtime และไม่มีอะไรให้ config เหลืออยู่แล้ว:

Error: Turbopack build failed with 1 error:
./app/r3b5/page.tsx:2:14
Error: Route segment config "runtime" is not compatible with `nextConfig.cacheComponents`. Please remove it.

วิธีแก้ก็แค่ลบ export นี้ทิ้ง ไม่ต้องมีตัวแทนใดๆ เลย — Node.js runtime ค่า default คือตัวเดียวที่ Cache Components รองรับ

ทั้งรูป thumbnail ใน grid และรูปในหน้ารายละเอียดสินค้าใช้ fill โดยไม่มี prop sizes

🐛 หา bug ในโค้ดนี้

<div style={{ position: 'relative', width: 400, height: 400 }}>
<Image src={product.image} alt={product.name} fill style={{ objectFit: 'cover' }} />
</div>
เฉลย

เมื่อไม่มี prop sizes Next.js จะตั้งค่า default ของรูป fill เป็น sizes="100vw" — เท่ากับบอก browser ว่ารูปนี้กว้างเท่า viewport ไม่ว่ากล่องจริงจะเล็กแค่ไหน build หน้า grid แล้วอ่าน HTML ที่ render ออกมา จะเห็นต้นทุนที่แท้จริง: ด้วย grid cell กว้าง 300px และไม่มี sizes browser เลือก candidate ที่กว้าง 640px — ตัวที่เล็กที่สุดที่ “ครอบคลุม” viewport 400px เต็มภายใต้สมมติฐาน 100vw:

sizes="100vw"
srcSet="/_next/image?url=%2Fproduct.png&w=640&q=75 640w, ... up to ... &w=3840&q=75 3840w"

วิธีแก้คือค่า sizes ที่ตรงกับความกว้างที่ render จริง: sizes="(max-width: 768px) 100vw, 33vw" สำหรับ grid cell ที่กว้างประมาณหนึ่งในสาม และ sizes="400px" แบบตายตัวสำหรับรูปในหน้ารายละเอียดสินค้าที่มีขนาดคงที่ เมื่อ build พร้อมวิธีแก้แล้ว รูปในหน้ารายละเอียดสินค้าจะขอ candidate ขนาด 400px แทนที่จะเป็นตัวที่ใหญ่ที่สุดใน deviceSizes

generateMetadata ตั้ง viewport เป็น field ใน object Metadata ที่คืนค่า

🐛 หา bug ในโค้ดนี้

export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const product = await getProduct(id)
return {
title: product?.name ?? 'Product',
viewport: 'width=device-width, initial-scale=1',
openGraph: { images: [product?.image ?? '/product.png'] },
}
}
เฉลย

viewport เคยเป็น field ของ metadata ใน Next.js เวอร์ชันเก่า วันนี้มันเป็น export ของตัวเอง (viewport หรือ generateViewport) การใส่มันไว้ข้างใน metadata ไม่ทำให้เกิด error — มันแค่ถูกทิ้งไป พร้อม warning ที่พลาดดูง่ายมากใน build log ยาวๆ:

⚠ Unsupported metadata viewport is configured in metadata export in /r3b3. Please move it to viewport export instead.
Read more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport

ผู้เยี่ยมชมทุกคนบนมือถือจะได้พฤติกรรม viewport ค่า default ของ browser (ปกติไม่ responsive) แทนที่จะเป็นค่าที่โค้ดนี้ตั้งใจจะตั้งไว้อย่างชัดเจน วิธีแก้คือ export คู่กัน export const viewport: Viewport = { width: 'device-width', initialScale: 1 } ใน root layout — ตั้งครั้งเดียวแล้วใช้กับทุกหน้า รวมถึงหน้านี้ด้วย

// app/layout.tsx (excerpt — the metadata/viewport exports only)
import type { Metadata, Viewport } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://shop.example.com'),
title: 'Create Next App',
description: 'Generated by create next app',
}
// FIX (viewport bug): viewport is its own export, never a field on metadata.
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
}
next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
lib/products.ts
export type Product = { id: string; name: string; price: number; image: string }
const products: Product[] = [
{ id: '1', name: 'Desk lamp', price: 2900, image: '/product.png' },
{ id: '2', name: 'Ceramic mug', price: 1200, image: '/product.png' },
{ id: '3', name: 'Notebook', price: 800, image: '/product.png' },
]
export async function listProducts() {
return products
}
export async function getProduct(id: string) {
return products.find((p) => p.id === id) ?? null
}
app/products/page.tsx
import Link from 'next/link'
import Image from 'next/image'
import { listProducts } from '@/lib/products'
export default async function ProductsPage() {
const products = await listProducts()
return (
<main>
<h1>Products</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{products.map((p) => (
// FIX (routing bug): <Link>, not a plain <a>, for an internal route.
<Link key={p.id} href={`/products/${p.id}`}>
<div style={{ position: 'relative', height: 200 }}>
{/* FIX (sizes bug): sizes matches the actual ~33vw grid cell */}
<Image src={p.image} alt={p.name} fill sizes="(max-width: 768px) 100vw, 33vw" style={{ objectFit: 'cover' }} />
</div>
<p>{p.name}</p>
</Link>
))}
</div>
</main>
)
}
app/products/[id]/actions.ts
'use server'
export async function addToCart(productId: string) {
console.log('added to cart', productId)
}
app/products/[id]/recently-viewed-widget.tsx
'use client'
export default function RecentlyViewedWidget() {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem('recent') : null
return <p>Recently viewed: {raw ?? 'none yet'}</p>
}
app/products/[id]/recently-viewed.tsx
'use client'
import dynamic from 'next/dynamic'
// FIX (boundary bug): ssr: false now lives inside a Client Component
// ('use client' above), which next/dynamic explicitly allows — it was only
// rejected when the Server Component page itself called dynamic() with
// ssr: false.
const RecentlyViewedWidget = dynamic(() => import('./recently-viewed-widget'), { ssr: false })
export function RecentlyViewed() {
return <RecentlyViewedWidget />
}
app/products/[id]/page.tsx
import Image from 'next/image'
import Link from 'next/link'
import type { Metadata } from 'next'
import { getProduct, listProducts } from '@/lib/products'
import { addToCart } from './actions'
import { RecentlyViewed } from './recently-viewed'
// FIX (caching bug): a real, non-empty list of params — required under
// cacheComponents.
export async function generateStaticParams() {
const products = await listProducts()
return products.map((p) => ({ id: p.id }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const product = await getProduct(id)
return {
title: product?.name ?? 'Product',
openGraph: { images: [product?.image ?? '/product.png'] },
}
}
// FIX (runtime bug): no runtime export at all — the default Node.js runtime
// is required under cacheComponents, and 'edge' is deprecated regardless.
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await getProduct(id)
if (!product) return <p>Not found</p>
return (
<main>
<p><Link href="/products">Back to products</Link></p>
<h1>{product.name}</h1>
<div style={{ position: 'relative', width: 400, height: 400 }}>
{/* FIX (sizes bug): a fixed sizes value for this fixed-size box */}
<Image src={product.image} alt={product.name} fill sizes="400px" style={{ objectFit: 'cover' }} />
</div>
<p>${(product.price / 100).toFixed(2)}</p>
<form action={addToCart.bind(null, product.id)}>
<button type="submit">Add to cart</button>
</form>
<RecentlyViewed />
</main>
)
}

build ผ่านสะอาดกับ Next.js 16.3.6 พร้อม cacheComponents: true — next build รายงาน error เป็นศูนย์ warning เป็นศูนย์ และ prerender ทุกหน้าสินค้าจริงตอน build:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /products
└ /products/[id]
├ ◐ /products/[id]
├ ○ /products/1
├ ○ /products/2
└ ○ /products/3

start เซิร์ฟเวอร์ที่ build แล้วและยิง request ไปที่หน้าสินค้าจริง ยืนยันการแก้ทุกจุดตั้งแต่ต้นจนจบ: og:image ที่ resolve ออกมาเป็น URL production จริงแบบ absolute (ไม่ใช่ localhost), รูปทั้งใน grid และหน้ารายละเอียดขอ candidate ขนาดที่เหมาะสม, ลิงก์สินค้าใน grid เป็น <a> จริงที่ render โดย <Link> (navigate ฝั่ง client ได้), widget ที่รันฝั่ง client เท่านั้น render ถูกต้อง, และ cart action log บนเซิร์ฟเวอร์พอดีหนึ่งครั้งต่อการคลิกหนึ่งครั้ง:

<meta property="og:image" content="https://shop.example.com/product.png"/>
sizes="400px" (product detail image)
sizes="(max-width: 768px) 100vw, 33vw" (grid thumbnails, ×3)
<a href="/products/1"> (rendered by <Link>)
"Recently viewed: none yet" (client-only widget, correctly client-rendered)
server log: added to cart 1
#Bugหมวดวิธีแก้
1dynamic(..., { ssr: false }) ข้างใน Server ComponentBoundariesเพิ่ม 'use client' ให้ไฟล์ที่เรียก dynamic()
2<a href={...}> สำหรับ route ภายในRouting<Link href={...}> จาก next/link
3generateStaticParams คืนค่า []Caching modelคืนค่าชุด param จริงจาก listProducts()
4export const runtime = 'edge' อยู่ข้างๆ cacheComponentsCaching modelลบ export ทิ้ง — Node.js คือ runtime เดียวที่รองรับ
5next/image ใช้ fill โดยไม่มี sizesOptimizationค่า sizes ที่ตรงกับความกว้างที่ render จริง
6viewport เป็น field ข้างใน metadataOptimizationexport const viewport: Viewport แยกต่างหาก

สี่ในหก bug ของรีวิวนี้เป็นข้อเท็จจริงตอน build ที่ next build ปฏิเสธตรงๆ อยู่แล้ว — ssr: false ข้างใน Server Component, generateStaticParams ที่ว่างเปล่า, และ runtime ภายใต้ Cache Components ล้วนล้มเหลวดังๆ ใน build เดียวกัน อีกสองจุด — <a> ธรรมดา และ sizes ที่หายไป — build ผ่านสะอาดหมดจด และมีต้นทุนจริงก็ต่อเมื่อ browser ของผู้เยี่ยมชมโหลดหน้านั้นจริงๆ: การ reload หน้าทั้งหมดแทนที่จะเป็นการ navigate ฝั่ง client แบบทันที และข้อมูลรูปภาพหลายเท่าตัวเกินกว่าที่ layout ต้องการ ทั้งสองอย่างไม่ปรากฏจากการอ่าน JSX เลย แต่ปรากฏใน network panel ทั้งคู่

  • “ssr: false เป็นแค่ hint ด้าน performance ที่คอมโพเนนต์ไหนก็ใช้ได้” มันคือคำขอที่มีแค่ Client Component เท่านั้นที่ทำได้กับตัวเอง — Server Component ไม่มี client fallback ให้ opt out ไปหา
  • “generateStaticParams ที่ว่างเปล่าแปลว่า next build แค่ข้ามการ prerender เหมือนเดิม” ภายใต้ Cache Components โดยเฉพาะ มันคือ build error ตรงๆ ไม่ใช่ no-op เงียบๆ — framework ต้องการ param อย่างน้อยหนึ่งตัวเพื่อ validate
  • “<a> ธรรมดาสำหรับลิงก์ภายในก็ยังใช้งานได้ แปลว่าไม่ใช่ bug จริง” มัน navigate ได้ถูกต้อง นี่แหละคือเหตุผลที่มันรอดจาก code review — ต้นทุนปรากฏก็ต่อเมื่อดูใน network panel เป็นการ reload ทั้งหมดและโหลด static asset ทุกตัวซ้ำ
flowchart TD
  A["1. Boundaries"] --> A1["ssr: false inside a Server Component"]
  B["2. Routing"] --> B1["a plain a instead of Link"]
  C["3. Caching model"] --> C1["empty generateStaticParams"]
  C --> C2["runtime = edge next to cacheComponents"]
  D["Optimization"] --> D1["fill image with no sizes"]
  D --> D2["viewport inside metadata"]
แต่ละ bug อยู่ตรงไหนในลำดับการอ่าน
เกิดอะไรขึ้นตอน build?
// no 'use client' in this file
import dynamic from 'next/dynamic'
const Widget = dynamic(() => import('./widget'), { ssr: false })
grid ลิงก์ไปหน้าสินค้าด้วย <a href={...}> แทนที่จะเป็น <Link> ความต่างที่สังเกตได้จริงใน browser จริงคืออะไร?
เมื่อเปิด cacheComponents: true เกิดอะไรขึ้นตอน build?
export async function generateStaticParams() {
  return []
}
เกิดอะไรขึ้นเมื่อโปรเจกต์นี้ build?
// next.config.ts
const nextConfig = { cacheComponents: true }
// app/products/[id]/page.tsx
export const runtime = 'edge'
เมื่อไม่มี prop sizes browser จะขอรูปประมาณขนาดไหนสำหรับกล่องกว้าง 300px นี้บน viewport 400px?
<div style={{ position: 'relative', width: 300, height: 200 }}>
  <Image src="/hero.jpg" alt="" fill style={{ objectFit: 'cover' }} />
</div>
Next.js ทำอะไรกับ field viewport ตรงนี้?
export const metadata: Metadata = {
  title: 'Product',
  viewport: 'width=device-width, initial-scale=1',
}