บทความ เรื่องราว ความรู้ และประสบการณ์จากการสร้างเว็บไซต์และ Web Application
BlogNext.js & Frontend Next.js SEO ทำยังไง เช็กลิสต์ App Router ใช้ Next.js แล้วไม่ได้ติด Google เอง สรุปปัญหาที่เจอจริงตอนแก้ SEO ของเว็บ KittiStack พร้อมโค้ดแก้ rendering, metadata, sitemap และ JSON-LD สำหรับ App Router
Kittipong C.
ทีมงาน KittiStack
October 26, 2026 · 8 min read
Next.js SEO ทำยังไง ให้ Google เห็นเนื้อหาครบ (คู่มือ App Router พร้อมโค้ด) Next.js SEO ทำได้ด้วย 4 เรื่องหลัก: ให้เนื้อหาสำคัญอยู่ใน HTML ที่ server ส่งมา ตั้ง title, description และ canonical ทุกหน้าด้วย Metadata API มี sitemap.xml กับ robots.txt และใส่ structured data ให้หน้าบทความ ส่วนที่คนพลาดบ่อยที่สุดไม่ใช่ meta tag แต่เป็นการดึงข้อมูลฝั่งเบราว์เซอร์จน HTML แรกว่างเปล่า
บทความนี้เขียนจากตอนแก้ SEO ของเว็บ KittiStack เอง ซึ่งสร้างด้วย Next.js และเจอปัญหาครบเกือบทุกข้อด้านล่าง โค้ดตัวอย่างใช้ App Router (Next.js 15–16) ถ้ายังไม่คุ้นกับ App Router อ่าน คู่มือ Next.js เบื้องต้น ก่อนได้
ใช้ Next.js แล้วติด Google อัตโนมัติไหม ไม่อัตโนมัติ Next.js แค่ทำให้ส่ง HTML ที่มีเนื้อหาครบได้ง่าย แต่ถ้าเขียนโค้ดแบบ React ทั่วไป คือดึงข้อมูลใน useEffect หลังหน้าโหลด ผลที่ได้ก็ไม่ต่างจากเว็บ React ล้วน
Google ทำงานเป็น 3 ขั้น คือ crawl, render และ index หน้าที่ HTML มีเนื้อหาครบตั้งแต่แรก Google อ่านได้ตั้งแต่ขั้น crawl ส่วนหน้าที่ต้องรัน JavaScript ก่อน จะต้องรอคิว render ซึ่ง เอกสาร JavaScript SEO ของ Google บอกว่าอาจรอไม่กี่วินาทีหรือนานกว่านั้น และยังแนะนำให้ render ฝั่ง server ไว้ก่อน เพราะเร็วกว่าทั้งกับคนและ crawler และ bot บางตัวรัน JavaScript ไม่ได้เลย
ปัญหาที่เจอจริงตอนตรวจเว็บ KittiStack
#Next.js #App Router #SEO #Server Components
ก่อนลงรายละเอียด นี่คือสิ่งที่เจอตอนเปิดดู HTML ที่ server ส่งมาของเว็บตัวเอง ถ้าเว็บคุณทำด้วย Next.js มีโอกาสสูงว่าจะเจออย่างน้อย 1 ข้อ
ทุกข้อในตารางนี้ เวลาเปิดเว็บด้วยเบราว์เซอร์ปกติจะดูไม่ออกเลย เพราะ JavaScript เติมข้อมูลให้ครบภายในเสี้ยววินาที ต้องดู HTML ดิบถึงจะเห็น
วิธีเช็กว่า Google เห็นเนื้อหาอะไรบ้าง ทำข้อนี้ก่อนแก้อะไรทั้งนั้น เพราะจะรู้ว่าปัญหาอยู่หน้าไหน
1. เปิดหน้าที่อยากเช็ก กด Ctrl+U (Mac: Cmd+Option+U) เพื่อดูซอร์สของหน้า ต้องเป็นเมนูนี้ ไม่ใช่ Inspect เพราะ Inspect แสดง DOM หลัง JavaScript ทำงานแล้ว 2. กด Ctrl+F ค้นประโยคจากย่อหน้าสุดท้ายของบทความ หรือชื่อสินค้า/โปรเจกต์ตัวใดตัวหนึ่ง ถ้าไม่เจอ แปลว่าส่วนนั้นไม่อยู่ใน HTML แรก 3. ค้น <title>, name="description" และ rel="canonical" ว่ามีครบและไม่ซ้ำกับหน้าอื่น 4. ถ้ายืนยันเว็บใน Google Search Console แล้ว ใช้เครื่องมือตรวจสอบ URL แล้วกดทดสอบ URL แบบเรียลไทม์ จะเห็น HTML ที่ Googlebot render ได้จริง ถ้าใช้ terminal ทำแบบเร็วได้ด้วยคำสั่งเดียว:
curl -s https://www.example.com/blog/my-post | grep -c "ประโยคจากท้ายบทความ"
แก้ rendering ก่อน: ดึงข้อมูลใน Server Component นี่คือข้อที่สำคัญที่สุดใน Next.js SEO ถ้าข้อนี้ยังไม่ผ่าน ทำ meta tag สวยแค่ไหนก็ช่วยได้น้อย
รูปแบบที่ทำให้ HTML ว่างมักหน้าตาแบบนี้:
// app/projects/page.tsx (แบบที่มีปัญหา)
'use client'
import { useEffect, useState } from 'react'
export default function ProjectsPage() {
const [ projects, setProjects] = useState([ ] )
useEffect(() => {
fetch('/api/projects').then((r) => r.json()).then(setProjects)
} , [ ] )
// HTML แรกที่ server ส่งมา projects ยังเป็น [ ] เสมอ
return <ProjectGrid projects={ projects} />
}
หน้านี้ยัง render บน server อยู่ แต่ตอนนั้น projects เป็นอาร์เรย์ว่าง HTML แรกจึงมีแค่ข้อความว่าไม่มีผลงาน ตรงกับที่เว็บ KittiStack เคยเป็น
วิธีแก้คือย้ายการดึงข้อมูลไปไว้ใน Server Component แล้วส่งข้อมูลลงไปให้ส่วนที่ต้องโต้ตอบ เช่น ตัวกรอง:
// app/projects/page.tsx (Server Component)
import { getProjects } from '@/lib/projects'
import ProjectFilter from './project-filter'
export default async function ProjectsPage() {
const projects = await getProjects()
return <ProjectFilter initialProjects={ projects} />
}
// app/projects/project-filter.tsx
'use client'
import { useState } from 'react'
export default function ProjectFilter({ initialProjects } ) {
const [ tag, setTag] = useState('all')
const shown = tag === 'all'
? initialProjects
: initialProjects.filter((p) => p.tags.includes(tag))
return <ProjectGrid projects={ shown} onTagChange={ setTag} />
}
ตัวกรองยังทำงานในเบราว์เซอร์เหมือนเดิม แต่ HTML แรกมีการ์ดผลงานครบแล้ว หลักง่าย ๆ คือ ข้อมูลที่อยากให้ติด Google ให้มาจาก server ส่วน 'use client' ใช้กับปุ่ม ตัวกรอง และฟอร์ม
ถ้าข้อมูลไม่ได้เปลี่ยนทุกนาที เช่น บทความบล็อก ให้ build เป็นหน้า static ไว้ล่วงหน้าด้วย generateStaticParams หน้าจะเร็วกว่า และ metadata ถูกสร้างไว้ตั้งแต่ตอน build
App Router มี Metadata API ในตัว ไม่ต้องลงแพ็กเกจอย่าง next-seo อีก แต่มีเงื่อนไขว่า metadata และ generateMetadata ใช้ได้เฉพาะใน Server Component ถ้าไฟล์ page มี 'use client' อยู่บรรทัดแรก ต้องแยกส่วน client ออกไปก่อน
ตั้งค่ากลางใน root layout // app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://www.example.com'),
title: {
default: 'ชื่อเว็บ | สิ่งที่เว็บทำ',
template: '%s | ชื่อเว็บ',
} ,
description: 'คำอธิบายเว็บที่ใช้เมื่อหน้าย่อยไม่ได้ตั้งเอง',
}
metadataBase ทำให้ใส่ canonical และรูป OG เป็น path สั้น ๆ ได้ ส่วน template เติมชื่อเว็บท้าย title ให้ทุกหน้าอัตโนมัติ ข้อดีที่เห็นชัดคือ ในฐานข้อมูลเก็บแค่ชื่อบทความ ไม่ต้องพิมพ์ "| ชื่อเว็บ" เอง ปัญหา H1 ที่มีชื่อเว็บติดมาก็หายไปด้วย
// app/blog/[ slug] /page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
type Props = { params: Promise<{ slug: string } > }
export async function generateMetadata({ params } : Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) return { }
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${ slug} ` } ,
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
images: [ post.coverUrl] ,
publishedTime: post.publishedAt,
} ,
}
}
export default async function PostPage({ params } : Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return (
<article>
<h1>{ post.title} </h1>
<div dangerouslySetInnerHTML={ { __html: post.html } } />
</article>
)
}
ใน Next.js 15 ขึ้นไป params เป็น Promise ต้อง await ก่อนใช้ ถ้าลอกโค้ดจากบทความเก่าที่เขียน params.slug ตรง ๆ จะเจอ error หรือคำเตือน
อย่าลืม cache ฟังก์ชันดึงข้อมูล โค้ดข้างบนเรียก getPost สองครั้ง คือใน generateMetadata กับใน page ถ้าดึงด้วย fetch Next.js จะรวมให้เป็นครั้งเดียวเอง แต่ถ้าใช้ client ของฐานข้อมูลโดยตรง เช่น Supabase ให้ครอบด้วย cache ของ React:
// lib/posts.ts
import { cache } from 'react'
export const getPost = cache(async (slug: string) => {
// ดึงบทความจากฐานข้อมูล
} )
ในหน้าที่ render แบบ dynamic, เอกสาร Next.js อธิบายว่า metadata จะถูกส่งตามมาทีหลังโดยไม่บล็อกการแสดงผล ยกเว้น bot ที่ต้องการ metadata ใน <head> เช่น Bingbot และ Twitterbot ซึ่ง Next.js ตรวจจาก User Agent แล้วส่งแบบเต็มให้ ส่วนหน้า static ไม่มีประเด็นนี้ เพราะ metadata ถูกสร้างตอน build ถ้าตอนดูซอร์สเห็น <title> ไปอยู่ใน <body> นี่คือเหตุผล ไม่ใช่ข้อผิดพลาด
sitemap.xml และ robots.txt สองไฟล์นี้ใน App Router เขียนเป็นโค้ดได้ ข้อดีคือบทความใหม่จะเข้า sitemap เองโดยไม่ต้องแก้ไฟล์
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'
const SITE = 'https://www.example.com'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: SITE, lastModified: new Date() } ,
{ url: `${ SITE} /services`, lastModified: new Date() } ,
...posts.map((p) => ({
url: `${ SITE} /blog/${ p.slug} `,
lastModified: new Date(p.updatedAt),
} )),
]
}
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: [ '/admin'] } ,
sitemap: 'https://www.example.com/sitemap.xml',
}
}
ใส่ lastModified เป็นวันที่แก้บทความจริง อย่าใช้ new Date() กับทุกบทความ เพราะจะบอก Google ว่าทุกหน้าเพิ่งเปลี่ยนทุกครั้งที่ build
เขียนเสร็จแล้วอย่าลืมส่ง sitemap ใน Search Console ตอน KittiStack เพิ่งเปิดเว็บ ลองค้นชื่อแบรนด์ตัวเองยังไม่เจอเว็บเลย เพราะยังไม่ได้ยืนยันเว็บและส่ง sitemap
ใส่ JSON-LD ให้หน้าบทความ Structured data ช่วยให้ Google รู้ว่าหน้านี้เป็นบทความ ใครเขียน เผยแพร่และแก้ไขเมื่อไร ใน App Router ให้ render เป็นแท็ก <script> ใน page เลย ตัวนี้ต้องอยู่ใน HTML แรกเหมือนเนื้อหา ห้ามเติมด้วย JavaScript ทีหลัง
// ใน PostPage
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: [ post.coverUrl] ,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: 'ชื่อผู้เขียนจริง', url: 'https://www.example.com' } ,
}
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={ {
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
} }
/>
<h1>{ post.title} </h1>
{ /* เนื้อหา */}
</article>
)
.replace(/</g, '\\u003c') กันกรณีข้อความในบทความมีแท็ก HTML แล้วไปปิด <script> ก่อนเวลา ใส่ไว้ทุกครั้งเสียเวลาไม่ถึงวินาที
ส่วน FAQPage schema ใส่ได้ แต่ Google เลิกแสดง FAQ แบบกดขยายในผลค้นหาแล้ว จึงไม่ต้องรีบทำ
เช็กลิสต์ Next.js SEO ก่อน deploy ข้อหน้ารวมเป็นข้อที่มองข้ามง่าย ถ้า /blog ดึงรายการบทความฝั่งเบราว์เซอร์ Google จะเจอบทความใหม่ได้จาก sitemap อย่างเดียว ลิงก์ภายในจากหน้ารวมก็ไม่ช่วยอะไร
คำถามที่พบบ่อย Next.js ดีต่อ SEO กว่า WordPress ไหมทั้งสองแบบติด Google ได้เท่ากันถ้าทำถูก Next.js ได้เปรียบเรื่องความเร็วและควบคุม HTML ได้ละเอียด แต่ต้องเขียนโค้ดเองทุกอย่าง ขณะที่ WordPress มีปลั๊กอิน SEO ช่วยทำให้ ดูการเปรียบเทียบเต็ม ๆ ได้ใน บทความ Next.js vs WordPress
ใช้ 'use client' แล้ว SEO เสียไหมไม่เสียโดยตัวมันเอง Client Component ยังถูก render เป็น HTML บน server ตอนโหลดหน้าแรก ปัญหาเกิดเมื่อข้อมูลถูกดึงใน useEffect หลังหน้าโหลด ถ้าข้อมูลส่งลงมาเป็น props จาก Server Component ก็ไม่มีปัญหา
ต้องลง next-seo ไหมถ้าใช้ App Router ไม่ต้อง Metadata API ทำได้ครบทั้ง title, description, canonical และ Open Graph ส่วนโปรเจกต์ที่ยังใช้ Pages Router ให้ใช้ next/head หรือ next-seo ต่อได้
ทำครบแล้ว แต่ Google ยังไม่เก็บหน้า ต้องทำยังไงเช็กก่อนว่ายืนยันเว็บใน Search Console และส่ง sitemap แล้ว จากนั้นใช้เครื่องมือตรวจสอบ URL กับหน้าที่อยากให้ติด แล้วกดขอให้จัดทำดัชนี เว็บใหม่อาจใช้เวลาหลายวันถึงหลายสัปดาห์กว่าจะเริ่มขึ้น
ทำไมเปิดเว็บแล้วเห็นเนื้อหาครบ แต่ดูซอร์สไม่เจอเพราะเบราว์เซอร์รัน JavaScript เติมเนื้อหาให้แล้ว ส่วนซอร์สของหน้าคือ HTML ก่อน JavaScript ทำงาน ซึ่งใกล้กับสิ่งที่ Google เห็นตอน crawl รอบแรกมากกว่า
สรุป ถ้ามีเวลาแก้ได้ข้อเดียว ให้เปิดซอร์สของหน้าสำคัญ 3–4 หน้า แล้วดูว่าเนื้อหาและลิงก์อยู่ครบไหม ข้อนี้ผ่านแล้วค่อยไล่ metadata, sitemap และ JSON-LD ตามเช็กลิสต์ ซึ่งทั้งหมดทำเสร็จได้ในวันเดียว
Next.js SEO ทำยังไง เช็กลิสต์ App Router | KittiStack