Metadata และ SEO
ไอเดียหลักในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียหลักในหนึ่งประโยค”คุณอธิบาย <head> ของหน้าเป็น data — object metadata แบบ static หรือฟังก์ชัน generateMetadata แบบ dynamic — แล้ว Next.js render tag ที่ถูกต้องบน server จึงไม่ต้องมาจัดการ <head> ด้วยมืออีก
metadata แบบ static
หัวข้อที่มีชื่อว่า “metadata แบบ static”คุณ export object metadata จาก layout หรือ page ไหนก็ได้ แล้ว Next.js เปลี่ยนค่านั้นเป็น <title>, <meta> และ link tag ขั้นตอนนี้รันบน server ดังนั้น tag จึงอยู่ใน HTML ชุดแรกที่ crawler อ่าน
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'About', description: 'Who we are and what we build.', openGraph: { title: 'About Acme', images: ['/og.png'] },}
export default function AboutPage() { return <h1>About</h1>}metadataBase: setting ที่หายไปแล้วทำ Open Graph image พังแบบเงียบ ๆ
หัวข้อที่มีชื่อว่า “metadataBase: setting ที่หายไปแล้วทำ Open Graph image พังแบบเงียบ ๆ”openGraph.images: ['/og.png'] ข้างบนเป็น path แบบ relative crawler ของ Open Graph กับ Twitter ต้องการ absolute URL ดังนั้น Next.js ต้องมี base ไว้ resolve ค่านั้น — คือ metadataBase ถ้าไม่ตั้งไว้ framework ไม่ทำให้ build fail แต่ fallback ไปที่ http://localhost:3000 แล้วเตือน พิสูจน์กับ root layout ของ probe (ซึ่งเหมือนตัวอย่างของคอร์สนี้ ไม่มี metadataBase ตั้งไว้):
⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000". See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabaseหมายความว่า og:image แบบ relative ทุกตัวใน production จะ resolve ไปที่ URL localhost แบบเงียบ ๆ จนกว่าคุณจะตั้งค่าเอง — social platform ที่มา fetch tag นี้จะได้ image ที่เข้าถึงไม่ได้
import type { Metadata } from 'next'
export const metadata: Metadata = { metadataBase: new URL('https://acme.com'), title: { template: '%s | Acme', default: 'Acme' },}metadataBase ต้องตั้งแค่ครั้งเดียวใน root layout (หรือที่ไหนก็ตามที่ root metadata อยู่) — URL แบบ relative ทุกตัวใน openGraph.images, twitter.images และ alternates.canonical ทั้งต้นไม้จะ resolve เทียบกับค่านี้
metadata แบบ dynamic, title template และ alternates.languages
หัวข้อที่มีชื่อว่า “metadata แบบ dynamic, title template และ alternates.languages”เมื่อ tag ขึ้นกับ data — blog post หรือ product — ให้ export generateMetadata แทน function นี้รับ params ของ route รันบน server และ return รูปแบบ Metadata เดียวกัน การ fetch ตรงนี้ถูก dedupe กับที่อยู่ในหน้าของคุณ คุณจึงไม่จ่ายซ้ำสองครั้ง ตั้งแต่ 15.2 ผลลัพธ์ของมันถูก stream — สำหรับ browser ทั่วไป Next.js ส่งหน้าไปก่อนโดยไม่รอ generateMetadata แล้วต่อ tag ที่ได้เข้าไปใน <body> เมื่อ resolve เสร็จ ส่วน HTML-limited bot ที่รัน JavaScript ไม่ได้ (Twitterbot, Slackbot, Bingbot — list default ของ htmlLimitedBots) จะถูก block และได้ tag ใน <head> ส่วน metadata แบบ static ลง <head> เสมอ
import type { Metadata } from 'next'
export async function generateMetadata({ params,}: { params: Promise<{ slug: string }>}): Promise<Metadata> { const { slug } = await params const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) => r.json()) return { title: post.title, description: post.excerpt }}layout กำหนด title.template ได้เพื่อให้ทุกหน้าลูกได้ suffix ที่สม่ำเสมอ และต้องมี default คู่กับ template เสมอ
// app/layout.tsx — every child title becomes "<page> | Acme"import type { Metadata } from 'next'
export const metadata: Metadata = { title: { template: '%s | Acme', default: 'Acme', },}คอร์สที่ส่งทุกบทเป็นสองภาษาแบบนี้คือเคสที่ alternates.languages มีไว้พอดี — มันสร้าง hreflang link ให้ search engine รู้ว่าหน้า /en กับ /th เป็นคำแปลของกันและกัน ไม่ใช่ content ซ้ำ
import type { Metadata } from 'next'
export const metadata: Metadata = { metadataBase: new URL('https://learn.example.com'), alternates: { languages: { en: '/en/lesson/images-and-fonts', th: '/th/lesson/images-and-fonts', }, },}head output ที่ render ออกมา — link rel="alternate" หนึ่งอันต่อภาษา resolve เทียบกับ metadataBase:
<link rel="alternate" hreflang="en" href="https://learn.example.com/en/lesson/images-and-fonts" /><link rel="alternate" hreflang="th" href="https://learn.example.com/th/lesson/images-and-fonts" />generateViewport กับ viewport แยกออกจาก metadata
หัวข้อที่มีชื่อว่า “generateViewport กับ viewport แยกออกจาก metadata”viewport (หรือ generateViewport สำหรับเวอร์ชัน dynamic) เป็น export ของตัวเอง — ไม่ใช่ field ข้างใน metadata ทั้งที่ Next.js เวอร์ชันเก่าเคยให้ตั้ง viewport แบบนั้น วางไว้ข้างใน metadata วันนี้ Next.js ไม่ merge ให้แบบเงียบ ๆ — มันตัดทิ้งแล้วเตือน พิสูจน์กับ probe จริง:
// app/viewport-bug/page.tsx — @expect-error viewport does not belong inside metadataimport type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Viewport bug demo', viewport: 'width=device-width, initial-scale=1',}⚠ Unsupported metadata viewport is configured in metadata export in /viewport-bug. Please move it to viewport export instead.Read more: https://nextjs.org/docs/app/api-reference/functions/generate-viewportวิธีแก้คือ export พี่น้องกัน ไม่ใช่ field:
// app/layout.tsx | page.tsximport type { Viewport } from 'next'
export const viewport: Viewport = { width: 'device-width', initialScale: 1, themeColor: 'black',}viewport/generateViewport รองรับเฉพาะใน Server Component เท่านั้น และ route segment เดียวจะ export ทั้ง viewport และ generateViewport พร้อมกันไม่ได้ — เลือกอย่างใดอย่างหนึ่ง
file-based metadata รวมถึง opengraph-image ที่สร้างด้วย ImageResponse
หัวข้อที่มีชื่อว่า “file-based metadata รวมถึง opengraph-image ที่สร้างด้วย ImageResponse”metadata บางอย่างเป็นไฟล์ ไม่ใช่ field วางไฟล์ที่ตั้งชื่อแบบพิเศษลงใน route segment แล้ว Next.js ต่อให้เอง opengraph-image.tsx สร้าง social preview, icon.png กลายเป็น favicon ส่วน sitemap.ts และ robots.ts กลายเป็น route ของตัวเอง route พิเศษเหล่านี้ถูก cache โดย default
// app/opengraph-image.tsx — generated, cached social imageimport { ImageResponse } from 'next/og'
export const size = { width: 1200, height: 630 }export const contentType = 'image/png'
export default function Image() { return new ImageResponse(<div style={{ fontSize: 96 }}>Acme</div>, size)}build แล้วเสิร์ฟบน probe จริง นี่คือไฟล์ที่ถูกสร้างขึ้นจริง ไม่ใช่ placeholder — curl ไปที่ route ที่ได้คืนค่า size กับ contentType ตรงตามที่ประกาศเป๊ะ:
$ curl -sD - -o /tmp/og.png http://localhost:3181/opengraph-imageHTTP/1.1 200 OKcontent-type: image/pngcache-control: public, max-age=0, must-revalidate$ file /tmp/og.png/tmp/og.png: PNG image data, 1200 x 630, 8-bit/color RGBA, non-interlacedเพราะเป็น file-based route ที่สร้างด้วย ImageResponse (ซึ่ง render JSX เป็น image ผ่าน Satori ไม่ใช่ browser) มันก็ต้องการ metadataBase เหมือนกันถ้าอ้าง path แบบ relative — setting เดียวกันจากก่อนหน้าครอบคลุมตรงนี้ด้วย
// app/sitemap.ts — becomes /sitemap.xmlimport type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap { return [{ url: 'https://acme.com', lastModified: new Date() }]}เพราะทุกอย่างเป็น data หรือเป็นไฟล์ จึงไม่มี <head> ให้คอย sync ด้วยมือ — template merge ทั้ง parent และ child และ tag ที่ซ้ำกันก็ไม่มีทางเพี้ยนออกจากกัน
flowchart TD A["metadata object (needs metadataBase for relative URLs)"] --> M[Merge parent + child] B["generateMetadata(params)"] --> M V["viewport / generateViewport (separate export)"] --> Head C["opengraph-image (ImageResponse) / sitemap / robots files"] --> M M --> Static["static metadata: tags in head"] M --> Stream["generateMetadata: streamed into body (HTML-limited bots: blocking, in head)"] Static --> Head[head] Stream --> Head
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มอง metadata เหมือนฟอร์มที่มี field จำเป็นตัวหนึ่งที่ validator ไม่บังคับ: metadataBase คือ field นั้น ไม่มีอะไรห้ามคุณ ship โดยไม่ตั้งมัน — framework เติม placeholder (localhost:3000) ให้แล้วผ่านไป — แต่ทุก relative image URL ที่ตามมาจะผิดแบบเงียบ ๆ ใน production ส่วน viewport ที่อยู่นอก metadata คือกับดักรูปแบบเดียวกันแต่กลับด้าน: API แบบรวมเดิม ๆ ยังคง parse ผ่าน ดังนั้นดูเหมือนไม่มีอะไรพังจนกว่าคุณจะสังเกตว่า tag ไม่เคย render ออกมาเลย
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
ตั้ง metadataBase ครั้งเดียวที่ root | URL แบบ relative ของ OG/Twitter/canonical ทุกตัวทุกที่ resolve ถูกต้อง | ลืมง่าย — fallback ซ่อนความผิดพลาดแทนที่จะ fail ดัง ๆ |
generateMetadata (stream) | หน้าถูกส่งโดยไม่ต้องรอ fetch dedupe กับของหน้าเอง | HTML-limited bot ยัง block รอมันอยู่ ดังนั้น fetch ที่ช้ายังส่งผลต่อ SEO |
alternates.languages | crawler มองบทที่แปลแล้วเป็น entity เดียวกัน ไม่ใช่ของซ้ำ | ต้อง sync กับโครงสร้าง route จริงของแต่ละภาษาตลอด |
opengraph-image.tsx + ImageResponse | สร้างให้อัตโนมัติ cache ให้ ไม่ต้องมี asset pipeline เอง | render ผ่าน Satori (ไม่ใช่ browser เต็มรูปแบบ) — CSS ที่รองรับเป็นแค่ subset |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ถ้าลืม
metadataBasebuild จะ fail ให้เห็นเอง” ไม่จริง — มันแค่เตือนแล้ว fallback ไปที่http://localhost:3000ซึ่งจะเห็นก็ต่อเมื่ออ่าน build output หรือตรวจ tag ที่ render จริงใน production - “
viewportก็แค่ field อีกตัวใน objectmetadataเหมือนtitle” เคยเป็นแบบนั้น แต่ตอนนี้เป็น export แยก (viewportหรือgenerateViewport) วางไว้ข้างในmetadataจะได้ warning ที่เห็นจริงและ tag ถูกตัดทิ้ง - “
alternates.languagesใช้ได้เฉพาะ domain แยกกันเท่านั้น” มันใช้ได้ดีกับ locale แบบ path (/en/...,/th/...) แบบคอร์สนี้ — ค่าแค่ต้องเป็น URL ที่ resolve ได้ (relative ก็ resolve เทียบกับmetadataBase) - “
opengraph-image.tsxรันใน browser จริง ดังนั้น CSS ใช้ได้ทุกอย่าง” มัน render ผ่าน Satori ไม่ใช่ browser engine — การรองรับ layout ใกล้เคียง flexbox subset ที่จำกัดมากกว่า CSS เต็มรูปแบบ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างแตะ metadata เช็กสิ่งเหล่านี้ก่อนเชื่อ:
- มี
openGraph.imagesหรือtwitter.imagesแบบ relative URL ที่ไหนสักที่โดยไม่มีmetadataBaseตั้งไว้ใน root metadata ไหม? มันจะ resolve ไปlocalhostแบบเงียบ ๆ ใน production viewport(หรือthemeColor,width,initialScale) อยู่ข้างใน objectmetadataแทนที่จะเป็น exportviewport/generateViewportของตัวเองไหม?- route segment export ทั้ง
viewportและgenerateViewportพร้อมกันไหม? อนุญาตแค่อย่างเดียว - สำหรับ route แบบสองภาษาหรือหลาย locale มี
alternates.languagesอยู่และชี้ไปที่ route พี่น้องจริงไหม? generateMetadatafetch ซ้ำกับที่หน้า fetch ไปแล้วโดยสมมติว่าไม่ถูก dedupe หรือเปล่า? (มันถูก dedupe จริง — แต่แค่ตอนที่ fetch นั้นเหมือนกันเป๊ะเท่านั้น)
🐛 หา bug ในโค้ดนี้
import type { Metadata } from 'next'
export const metadata: Metadata = {title: 'Acme',viewport: 'width=device-width, initial-scale=1',openGraph: { images: ['/og.png'],},}เฉลย
มี bug จริงสองตัวที่นี่ ทั้งคู่เงียบ อันแรก viewport เป็น field ข้างใน metadata — นี่คือรูปแบบก่อน 16 วันนี้มันต้องเป็น export const viewport ของตัวเอง (หรือ generateViewport) และ Next.js จะตัดมันทิ้งพร้อม warning ตอน build (“Unsupported metadata viewport is configured in metadata export… Please move it to viewport export instead”) แทนที่จะเอาไปใช้ อันที่สอง openGraph.images: ['/og.png'] เป็น relative และไม่มี metadataBase อยู่ใน layout นี้เลย — พิสูจน์ใน probe แล้วว่าคู่นี้ทำให้เกิด “metadataBase property in metadata export is not set… using “http://localhost:3000\"" หมายความว่า tag OG image จริงใน production จะชี้ไป URL ที่มีอยู่แค่ในเครื่อง dev เท่านั้น วิธีแก้: ย้าย viewport ไปเป็น export ของตัวเอง แล้วเพิ่ม metadataBase: new URL('https://acme.com') ไว้ข้าง title