Image และ Font
ไอเดียหลักในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียหลักในหนึ่งประโยค”next/image resize, แปลงเป็น format ใหม่ และ lazy-load image ให้พร้อมจองพื้นที่ไว้ล่วงหน้า ส่วน next/font self-host font ตอน build — หน้าเว็บจึงโหลดเร็วและไม่ขยับ
next/image resize, แปลง format และ lazy-load
หัวข้อที่มีชื่อว่า “next/image resize, แปลง format และ lazy-load”Image คือตัวแทนแบบ drop-in ของ <img> ที่ optimize ให้ตอน request โดยสร้าง version ที่ resize ตามอุปกรณ์ ส่ง WebP เมื่อ browser รองรับ (และ AVIF ด้วย ถ้าคุณเพิ่มเข้าไปใน images.formats — default ของ formats มีแค่ ['image/webp'] เท่านั้น) และ lazy-load ทุกอย่างที่อยู่ใต้ fold ให้อัตโนมัติ
เมื่อคุณ import ไฟล์ในเครื่อง Next.js อ่านขนาดจริงตอน build ดังนั้น width กับ height จึงถูกเติมให้เอง
import Image from 'next/image'import hero from './hero.png'
export default function Page() { return <Image src={hero} alt="Product hero shot" />}remote image ต้องมี remotePatterns ส่วน local image ที่มี query string ต้องมี localPatterns
หัวข้อที่มีชื่อว่า “remote image ต้องมี remotePatterns ส่วน local image ที่มี query string ต้องมี localPatterns”การ optimize external URL ตามใจชอบเท่ากับเปิด open proxy Next.js จึงปฏิเสธจนกว่าคุณจะ allowlist host นั้น ประกาศ images.remotePatterns ใน next.config และระบุให้เจาะจงที่สุดเท่าที่ทำได้
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com', pathname: '/**' }, ], },}
export default nextConfigตั้งแต่ Next.js 16 หลักการ allowlist เดียวกันนี้ใช้กับ image local ที่มี query string ด้วย (/assets/photo?v=1) — เดิมทีสิ่งนี้ optimize ได้ฟรี แต่ query string ที่ไม่จำกัดบน local path ถูกใช้โจมตีแบบ enumeration ได้ จึงกลายเป็น ข้อจำกัดตอน build/runtime: คุณต้องเพิ่ม pattern ที่ตรงเป๊ะลงใน images.localPatterns รวม search ด้วย
// next.config.ts — required since 16 for local images with a query stringimport type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { localPatterns: [{ pathname: '/assets/**', search: '?v=1' }], },}
export default nextConfigถ้าละ search ไว้ จะอนุญาตทุก query string บน path นั้น — ควร pin เป็นค่าที่แน่นอน เว้นแต่จำเป็นต้องใช้ wildcard จริง ๆ
qualities และ minimumCacheTTL: allowlist สองตัวที่ค่า default เปลี่ยนใน 16
หัวข้อที่มีชื่อว่า “qualities และ minimumCacheTTL: allowlist สองตัวที่ค่า default เปลี่ยนใน 16”field images สองตัวเปลี่ยนค่า default แบบเงียบ ๆ ใน Next.js 16 และทั้งคู่มีมิติด้าน security ไม่ใช่แค่ performance
qualities เดิมรับ quality ค่าไหนก็ได้ ตั้งแต่ 16 เป็น allowlist ที่ต้องประกาศ default คือ [75] เท่านั้น
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { qualities: [25, 50, 75, 100], },}
export default nextConfigถ้า prop <Image quality={80}> ไม่ตรงกับค่าที่อนุญาต Next.js จะ coerce ให้เป็นค่าที่ใกล้ที่สุดในลิสต์แบบเงียบ ๆ แต่ถ้ายิง endpoint ของ optimizer ตรง ๆ ด้วย quality ที่ไม่อยู่ในลิสต์ มันปฏิเสธทันที — พิสูจน์กับ probe จริง (next start ใหม่ ไม่ override qualities ดังนั้น default allowlist [75] ทำงานอยู่):
$ curl -sD - -o /dev/null "http://localhost:3181/_next/image?url=%2Fhero.jpg&w=640&q=80"HTTP/1.1 400 Bad RequestminimumCacheTTL เปลี่ยนจาก 60 วินาที เป็น 4 ชั่วโมง (14400 วินาที) ตั้งแต่ Next.js 16 — ยืนยันด้วยการอ่าน header Cache-Control จริงที่ optimizer ส่งมาตอน cache miss:
$ curl -sD - -o /dev/null "http://localhost:3181/_next/image?url=%2Fhero.jpg&w=640&q=75"HTTP/1.1 200 OKCache-Control: public, max-age=14400, must-revalidate14400 ตรงกับ default ใหม่เป๊ะ — framework เลือก cache ที่อยู่ทนกว่าแทนการ revalidate บ่อย ๆ โดยมองว่า image ต้นทางส่วนใหญ่ที่ไม่มี Cache-Control header เป็นของตัวเองมักไม่ค่อยเปลี่ยน override ได้ต่อโปรเจกต์ถ้าต้องการ image ใหม่กว่านั้น:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { minimumCacheTTL: 60 }, // pre-16 behavior}
export default nextConfigsizes, srcset และการโหลด image ที่ใหญ่เกินจำเป็น
หัวข้อที่มีชื่อว่า “sizes, srcset และการโหลด image ที่ใหญ่เกินจำเป็น”sizes บอก browser ว่า image จะ render กว้างแค่ไหนจริง ๆ ที่แต่ละ breakpoint เพื่อให้เลือก candidate ที่เล็กที่สุดแต่พอดีจาก srcset ที่ Next.js สร้างให้ ถ้าไม่ใส่ sizes บน image ที่เป็น fill Next.js จะตั้งค่า default ของ sizes เป็น 100vw — browser จึงคิดว่า image กว้างเท่า viewport ไม่ว่ากล่องจริงจะเล็กแค่ไหนก็ตาม
พิสูจน์ใน probe ด้วย source image ขนาด 1600×1600 ที่อยู่ใน fill box กว้าง 300px:
// app/sizes-wrong/page.tsx — no sizes propimport Image from 'next/image'
export default function Page() { return ( <div style={{ width: 300, position: 'relative', height: 200 }}> <Image src="/hero.jpg" alt="wrong sizes demo" fill style={{ objectFit: 'cover' }} /> </div> )}HTML ที่ render ออกมาโชว์ตัวเลือกจริงที่ browser มี:
sizes="100vw"srcSet="/_next/image?url=%2Fhero.jpg&w=640&q=75 640w, /_next/image?url=%2Fhero.jpg&w=750&q=75 750w, ... up to /_next/image?url=%2Fhero.jpg&w=3840&q=75 3840w"มีแค่ tier ของ deviceSizes (640, 750, 828, 1080, 1200, 1920, 2048, 3840) เท่านั้นที่ถูกเสนอ — tier เล็กของ imageSizes (32...384) หายไปทั้งหมด เพราะ Next.js จะใส่ candidate เล็ก ๆ พวกนี้ก็ต่อเมื่อ sizes บอกว่า image เล็ก กว่า เต็ม viewport เท่านั้น บน viewport กว้าง 400px sizes="100vw" จะกลายเป็น 400px browser จึงเลือก candidate เล็กที่สุดที่ยังครอบคลุมได้: 640w — มากกว่าพื้นที่ pixel ที่กล่อง 300px ต้องการจริงกว่า 4 เท่า
ใส่ sizes ค่าจริงลงไป tier เล็กจะกลับมา แล้ว browser จะโหลดสิ่งที่ใกล้เคียงกับที่แสดงจริงมากขึ้น:
// app/sizes-right/page.tsx — sizes matches the actual rendered widthimport Image from 'next/image'
export default function Page() { return ( <div style={{ width: 300, position: 'relative', height: 200 }}> <Image src="/hero.jpg" alt="right sizes demo" fill sizes="300px" style={{ objectFit: 'cover' }} /> </div> )}sizes="300px"srcSet="... 32w, 48w, 64w, 96w, 128w, 256w, 384w, 640w, ..."ตอนนี้ tier เต็มของ imageSizes กลับมาแล้ว viewport 400px จะเลือก 384w สำหรับจอ 1x — เวอร์ชัน sizes ผิดโหลดกว้างเกินไปกว่า 2.7 เท่า (และพื้นที่ pixel ราว 7 เท่า) ของที่ต้องการจริง
priority ถูก deprecate แล้ว หันไปใช้ preload ส่วน loading กับ fetchPriority ใช้ที่เหลือ
หัวข้อที่มีชื่อว่า “priority ถูก deprecate แล้ว หันไปใช้ preload ส่วน loading กับ fetchPriority ใช้ที่เหลือ”ตั้งแต่ Next.js 16 prop priority ถูก deprecate — docs ที่ bundle มากับเวอร์ชันนี้เขียนตรง ๆ ว่า “Starting with Next.js 16, the priority property has been deprecated in favor of the preload property in order to make the behavior clear” priority ยังทำงานได้อยู่วันนี้ แต่โค้ดใหม่ควรใช้ preload แทน
// app/page.tsx — current API: preload replaces priorityimport Image from 'next/image'
export default function Hero() { return <Image src="/hero.jpg" alt="Hero" width={1600} height={900} preload />}ใช้ preload กับ image ตัวที่เป็น Largest Contentful Paint (LCP) — โดยทั่วไปคือ hero image เหนือ fold เมื่อมี image มากกว่าหนึ่งตัวที่อาจเป็น LCP ขึ้นกับ viewport (เคส responsive hero ที่พบบ่อย) docs แนะนำให้ใช้ loading="eager" หรือ fetchPriority="high" แทน preload เพราะ preload แทรก <link> ใน <head> แบบไม่มีเงื่อนไข ไม่ว่า browser จะต้องการ image ตัวไหนจริง ๆ
// Multiple viewport-dependent hero candidates: fetchPriority, not preload<Image src="/hero-mobile.jpg" alt="" width={400} height={600} fetchPriority="high" />loading ยังควบคุม lazy-vs-eager แบบเดิม — lazy (default) จะเลื่อนโหลดจนกว่าจะใกล้ viewport, eager โหลดทันทีไม่ว่าจะอยู่ตำแหน่งไหน
unoptimized: self-host โดยไม่มี sharp
หัวข้อที่มีชื่อว่า “unoptimized: self-host โดยไม่มี sharp”Image Optimization ต้องการ sharp บน server พิสูจน์ใน probe แล้ว: เอา sharp ออกจาก node_modules แล้วยิง request ไป /_next/image ไม่ error — มันเสิร์ฟไฟล์ต้นฉบับขนาดเต็มแบบเงียบ ๆ (source 1600×1600 กลับมาเป็น 1600×1600 เหมือนเดิม ทั้งที่ขอ w=750) แทนที่จะ fail แบบชัดเจนหรือแปลง format ให้ การ fallback แบบเงียบ ๆ นี้แย่กว่าการเลือกอย่างชัดเจน — ถ้า deploy target ติดตั้ง sharp ไม่ได้จริง ๆ ให้ตั้ง images.unoptimized: true เองเพื่อให้พฤติกรรมนี้ถูกบันทึกไว้ใน config ไม่ใช่มาเจอทีหลังจากการอ่าน response header ตอน production
// next.config.ts — intentional opt-out, e.g. static export or a host without sharpimport type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { unoptimized: true },}
export default nextConfigunoptimized ยังเหมาะกับ image รายตัวที่ไม่ได้ประโยชน์จาก optimize เลย — ไอคอนเล็ก ๆ ต่ำกว่า 1KB, SVG หรือ GIF แบบ animated
next/font self-host ตอน build
หัวข้อที่มีชื่อว่า “next/font self-host ตอน build”next/font ดาวน์โหลดไฟล์ font ตอน build แล้วเสิร์ฟจาก origin ของคุณเอง ไม่มี request ตอน runtime ไป Google และเพราะรู้ metric ของ font ล่วงหน้า จึงจองพื้นที่ได้ถูกต้องและได้ layout shift เป็นศูนย์
// app/layout.tsx — self-hosted, zero layout shift, no request to Googleimport { Inter } from 'next/font/google'import localFont from 'next/font/local'
const inter = Inter({ subsets: ['latin'], display: 'swap' })const brand = localFont({ src: './brand.woff2' })
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className={inter.className}> <body className={brand.className}>{children}</body> </html> )}flowchart LR Src[Source asset] --> Build[Build step] Build --> Opt["Resized, WebP-only by default, self-hosted font"] Opt --> Reserve["width + height (or sizes) reserve space"] Reserve --> Page["No layout shift, fast LCP"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”คิดว่า next/image เป็นตู้ขายของอัตโนมัติที่มีขนาดตายตัวให้เลือก (deviceSizes + imageSizes) ไม่ใช่เครื่องถ่ายเอกสารที่พิมพ์ขนาดไหนก็ได้ตามสั่ง sizes คือคุณบอกตู้ว่าคุณจะใช้ช่องไหนจริง ๆ ถ้าไม่บอก ตู้จะสมมติว่าคุณอยากได้ช่องใหญ่สุด “เผื่อไว้ก่อน” เพราะนั่นคือข้อสมมติที่ปลอดภัยที่สุดสำหรับ image แบบเต็มความกว้าง ส่วน qualities กับ minimumCacheTTL คือกฎความปลอดภัยของตู้เอง — เมนู quality คงที่แทนปุ่มหมุนไม่จำกัด และอายุการเก็บ default ที่ยาวขึ้นเพื่อไม่ต้อง restock ทุกนาที
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
ตั้ง sizes ถูกต้อง | browser โหลด candidate เล็กสุดที่พอดี | ต้อง sync prop นี้กับ CSS layout จริงอีกตัว |
qualities allowlist (default [75]) | ต้นทุน optimizer คาดเดาได้ ไม่ถูกยิง quality ตามใจ | ต้องเพิ่มทุกค่า quality ที่ design ใช้จริง |
minimumCacheTTL: 14400 (default) | revalidate น้อยลง ต้นทุน optimizer ต่ำลง | image เก่าอยู่ได้นานถึง 4 ชม. ถ้า upstream ไม่มี Cache-Control |
unoptimized: true | ใช้ได้ทุกที่ แม้ไม่มี sharp | ไม่ resize ไม่แปลง format — ต้นทุนนั้นย้ายไปที่อื่น |
preload / fetchPriority="high" | ควบคุมชัดเจนว่า image ไหนถูก prioritize | ถ้า mark ผิด image จริงที่เป็น LCP จะถูกหน่วง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
fillไม่ใส่sizesก็ใช้ได้ เพราะกล่องมี CSS width ตายตัวอยู่แล้ว” Next.js ไม่ได้อ่าน CSS ของคุณ — มันเห็นแค่ propsizes(หรือ default100vw) กล่องที่มองด้วยตาแล้วเล็ก แต่ไม่มีsizesก็ยังขอ candidate ขนาดเต็ม viewport อยู่ดี - “
priorityยังเป็นวิธี mark LCP image แบบทันสมัยอยู่” มันถูก deprecate ตั้งแต่ Next.js 16 หันไปใช้preloadแทน และใช้fetchPriority/loading="eager"สำหรับเคสที่มีหลาย candidate - “prop
qualityที่ผิดจะถูกเมินไปเฉย ๆ” มันถูก coerce เป็นค่าที่ใกล้ที่สุดในimages.qualitiesเมื่อใช้ผ่าน<Image>— แต่ถ้ายิง endpoint ของ optimizer ตรง ๆ ด้วย quality ที่ไม่อนุญาต จะถูกปฏิเสธด้วย400 - “ถ้าไม่มี
sharpimage optimization จะ fail แบบชัดเจนให้เห็นเลย” ไม่จริง — มันเสิร์ฟไฟล์ต้นฉบับขนาดเต็มแบบเงียบ ๆ ตั้งunoptimized: trueเองถ้านั่นคือ deploy ที่คุณต้องการจริง ๆ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างแตะ next/image เช็กสิ่งเหล่านี้ก่อนเชื่อ:
- image ที่เป็น
fillมีsizesที่ตรงกับความกว้างที่ render จริงไหม ไม่ใช่แค่ “อะไรก็ได้ที่ compile ผ่าน”? ไม่มีsizesบนfillจะ default เป็น100vwแบบเงียบ ๆ - ใช้
priorityตรงจุดที่preload(หรือfetchPriority/loading="eager"สำหรับ LCP หลาย candidate) เป็น API ปัจจุบันหรือเปล่า? - image ที่เป็น remote หรือ local ที่มี query string ขาด
images.remotePatterns/images.localPatternsที่ต้องมีเพื่อให้โหลดได้หรือเปล่า? - prop
qualityสมมติว่าใช้ค่า 1–100 ค่าไหนก็ได้ โดยไม่รู้ว่าimages.qualitiesdefault เป็น[75]เท่านั้นใน Next.js 16 หรือเปล่า? - โค้ดสมมติว่ามี
sharpอยู่โดยไม่ทั้งติดตั้งและไม่ทั้งตั้งunoptimized: trueให้ชัดเจนสำหรับ target ที่ไม่มีsharpหรือเปล่า?
🐛 หา bug ในโค้ดนี้
import Image from 'next/image'
export default function ProductGrid() {return ( <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}> {[1, 2, 3].map((id) => ( <div key={id} style={{ position: 'relative', height: 200 }}> <Image src={`/products/${id}.jpg`} alt="" fill style={{ objectFit: 'cover' }} /> </div> ))} </div>)}เฉลย
แต่ละช่องของ grid render กว้างประมาณหนึ่งในสามของ viewport แต่ไม่มี sizes prop บน fill image ตัวไหนเลย — Next.js จะ default sizes เป็น 100vw ดังนั้นทั้งสาม image จึงขอ candidate ที่กว้างเต็ม viewport จาก tier deviceSizes (640w ขึ้นไป) ทั้งที่แต่ละช่องกว้างแค่ ~33vw พิสูจน์ใน probe แล้ว: กล่อง 300px ที่ไม่มี sizes โหลด candidate อย่างน้อย 640w ส่วนกล่องเดียวกันที่มี sizes="300px" โหลดแค่ 384w — วิธีแก้คือใส่ sizes="(max-width: 768px) 100vw, 33vw" (หรือความกว้างเป็น pixel ตายตัวถ้า grid ไม่ responsive)