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

Tag pages

apps/web/app/tags/[slug]/page.tsx — Server Component แบบ dynamic-segment ที่เอา query posts ของ The home list มาใช้ซ้ำ คราวนี้กรองด้วย tag: slug ด้วย ISR และการแบ่งหน้ารูปแบบเดียวกัน apps/web/app/tags/page.tsx — index ธรรมดาที่แสดงทุก Tag จาก query tags และการอัปเดตเล็ก ๆ แต่จริงจังให้ apps/web/components/PostCard.tsx และ Card.module.css จาก Styling: แต่ละ tag ที่การ์ดเรนเดอร์จะกลายเป็นลิงก์ของตัวเองไปยังหน้า tag นั้น แทนที่จะเป็นข้อความคั่นจุลภาคเฉย ๆ

[slug] ตรงนี้ทำงานเหมือนกับ [slug] ใน Post page เป๊ะ: ชื่อโฟลเดอร์ในวงเล็บเหลี่ยมผูก segment ไหนก็ตามของ URL ที่อยู่ตำแหน่งนั้นเข้ากับ params.slug await แบบเดียวกัน ความต่างอยู่ที่ความหมายของ string นั้นต่อ query — slug ของ post page ใช้ค้นหา Post ตัวเดียวจาก slug เฉพาะของโพสต์นั้น ส่วน slug ของ tag page ส่งต่อตรง ๆ เป็นอาร์กิวเมนต์ tag ของ posts เพื่อกรองรายการ ไม่ใช่ค้นหา document เดียว PostsResolver.posts จาก Posts resolver ไม่รู้จักแนวคิด “tag page” ด้วยซ้ำ ทำแค่กรอง Post.tags หา string ที่ตรงกัน ที่เป็น string[] แบบ denormalized เดียวกับที่ Tags resolver บรรยายไว้แล้วว่าไม่มีความสัมพันธ์จริงกับ collection Tag เลย

ข้อเท็จจริงข้อสุดท้ายนั้นมีผลจริงที่หน้านี้ต้องจัดการอย่างตรงไปตรงมา: ต่างจาก post slug ที่ post(slug) หาเจอหรือคืน null ตรง ๆ tag slug ไม่มีสัญญาณ “ไม่มีอยู่จริง” ที่เทียบเท่าจาก posts(tag: slug) เพียงอย่างเดียว — tag slug ที่ไม่รู้จักก็แค่คืน total: 0 และ items ว่างเปล่ากลับมา response แบบเดียวกันเป๊ะกับที่ tag จริงที่บังเอิญยังไม่มีโพสต์ publish แล้วเลยจะให้กลับมา ไม่มีทางบอกได้ว่า “ยังไม่มีใครเขียนเกี่ยวกับ raspberry-pi” ต่างจาก “raspbery-pi พิมพ์ผิดของ tag ที่ไม่มีอยู่จริง” จากรูปร่างของ response ของ query นี้เพียงอย่างเดียว หน้านี้จึงไม่แกล้งทำเป็นรู้ด้วยการเรียก notFound() กับผลลัพธ์ว่างเปล่า เพราะนั่นจะ 404 tag จริงที่แค่บังเอิญยังว่างอยู่ แต่เลือกเรนเดอร์ “No posts found for this tag” แทน เป็นคำตอบที่ตรงไปตรงมาต่อคำถามที่ API ตอบได้แม่นยำกว่านั้นไม่ได้จริง ๆ

การอัปเดต PostCard เป็นบทเรียนเล็ก ๆ ที่เป็นรูปธรรมเรื่องความถูกต้องของ HTML ไม่ใช่แค่เรื่อง styling Styling สร้างทั้งการ์ดเป็น <Link href={.../posts/slug} className={styles.card}> ก้อนเดียวที่ห่อทุกอย่าง รวมถึงข้อความ tag ที่คั่นด้วยจุลภาค ทันทีที่ tag ต้องกลายเป็นลิงก์ของตัวเองไปที่ /tags/<slug> โครงสร้างนั้นก็พัง: <Link> เรนเดอร์ออกมาเป็น element <a> จริง และ <a> ที่ซ้อนอยู่ใน <a> อีกตัวเป็น HTML ที่ไม่ถูกต้อง browser จะปิด anchor ด้านนอกก่อนเวลาแบบเงียบ ๆ เพื่อรับมือ ผลคือการคลิกสิ่งที่ดูเหมือนยังอยู่ในลิงก์การ์ดอาจไม่พาไปโพสต์เลย และพฤติกรรมต่างกันไปตาม browser ซึ่งมองจากหน้าที่เรนเดอร์แล้วก็แทบไม่เห็น วิธีแก้คือเชิงโครงสร้าง: wrapper ด้านนอกกลายเป็น <div className={styles.card}> ธรรมดา รูปปก, ชื่อเรื่อง, และ excerpt ย้ายเข้าไปใน <Link> ของตัวเองไปที่โพสต์ และแต่ละ tag ได้ <Link> แยกของตัวเองไปที่หน้า tag ในฐานะ sibling ไม่ใช่ descendant ของลิงก์โพสต์

Route แบบ dynamic segment ต่อ tag (/tags/[slug], บทเรียนนี้) เทียบกับ query-string filter (/?tag=slug) การตัดสินใจแบบเดียวกับที่การแบ่งหน้า ?page= ของ The home list เองทำไว้สำหรับแกนที่ต่างกัน การแบ่งหน้ายังเป็น query string โดยตั้งใจ เพราะหน้า 2 ของ home list ไม่ใช่เนื้อหาที่ต่างจากหน้า 1 อย่างมีความหมาย เป็นรายการชุดเดิมที่ไล่ต่อลงไปอีกหน่อย จึงไม่สมควรมี URL ของตัวเอง ไม่ปรากฏใน sitemap.ts และ search engine มักถูกบอกให้มองตัวแปรที่แบ่งหน้าเป็นหน่วยเดียว แทนที่จะ index แยกทีละหน้า ในทางกลับกัน tag ระบุเนื้อหาที่ต่างออกไปจริง ๆ “โพสต์เกี่ยวกับ nestjs” เป็นปลายทางที่แชร์ได้ bookmark ได้ และ index ได้ในตัวเอง จึงคุ้มที่จะมี path จริงและรายการของตัวเองใน sitemap การให้ tag มี route จริงมีต้นทุนแค่อีกหนึ่งโฟลเดอร์ (app/tags/[slug]/) และตาม trade-off ที่เทียบเท่ากันของ Post page การตัดสินใจ generateStaticParams แบบ optional ที่บทเรียนนี้ไม่ได้เพิ่ม (รายการโพสต์ของ tag เปลี่ยนบ่อยเกินไป — ทุกโพสต์ใหม่แตะรายการนี้ — จนไม่คุ้มที่จะ pre-build เลย) การให้การแบ่งหน้ามี route จริงแทน query string จะไม่ได้อะไรเลย แถมเพิ่ม route ที่ไม่มีตัวตนที่แยกจากกันจริง ๆ ของตัวเอง

อัปเดต apps/web/components/Card.module.css — เพิ่ม styling ของลิงก์ tag และเปลี่ยนชื่อเป้าหมาย hover ของการ์ดเอง:

.card {
display: block;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-4);
}
.card:hover {
border-color: var(--color-primary);
}
.titleLink {
display: block;
}
.cover {
width: 100%;
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.title {
font-size: var(--text-lg);
margin: 0 0 var(--space-2);
}
.excerpt {
color: var(--color-muted);
margin: 0 0 var(--space-3);
}
.meta {
display: flex;
gap: var(--space-3);
font-size: var(--text-sm);
color: var(--color-muted);
}
.tags a {
color: var(--color-primary);
}
.tags a:hover {
text-decoration: underline;
}

อัปเดต apps/web/components/PostCard.tsx ให้การ์ดเป็น <div> ไม่ใช่ <Link> โดยลิงก์โพสต์และลิงก์แต่ละ tag เป็น sibling กัน:

import Link from 'next/link';
import styles from './Card.module.css';
import type { Post } from '@/lib/graphql';
interface PostCardProps {
post: Pick<Post, 'title' | 'slug' | 'excerpt' | 'coverImage' | 'tags' | 'publishedAt'>;
}
export function PostCard({ post }: PostCardProps) {
return (
<div className={styles.card}>
<Link href={`/posts/${post.slug}`} className={styles.titleLink}>
{post.coverImage && <img className={styles.cover} src={post.coverImage} alt="" />}
<h2 className={styles.title}>{post.title}</h2>
{post.excerpt && <p className={styles.excerpt}>{post.excerpt}</p>}
</Link>
<div className={styles.meta}>
{post.publishedAt && <time dateTime={post.publishedAt}>{post.publishedAt}</time>}
{post.tags.length > 0 && (
<span className={styles.tags}>
{post.tags.map((tag, index) => (
<span key={tag}>
{index > 0 && ', '}
<Link href={`/tags/${tag}`}>{tag}</Link>
</span>
))}
</span>
)}
</div>
</div>
);
}

สร้าง apps/web/app/tags/page.tsx:

import Link from 'next/link';
import { gqlFetch } from '@/lib/graphql';
import type { Tag } from '@/lib/graphql';
const TAGS_QUERY = `
query Tags {
tags {
id
name
slug
}
}
`;
export default async function TagsIndexPage() {
const { tags } = await gqlFetch<{ tags: Tag[] }>(TAGS_QUERY, undefined, {
revalidate: 300,
tags: ['tags-list'],
});
return (
<>
<h1>Tags</h1>
<ul>
{tags.map((tag) => (
<li key={tag.id}>
<Link href={`/tags/${tag.slug}`}>{tag.name}</Link>
</li>
))}
</ul>
</>
);
}

สร้าง apps/web/app/tags/[slug]/page.tsx:

import Link from 'next/link';
import { gqlFetch } from '@/lib/graphql';
import type { PostPage } from '@/lib/graphql';
import { PostCard } from '@/components/PostCard';
import styles from '@/components/PostGrid.module.css';
const PAGE_SIZE = 10;
const POSTS_BY_TAG_QUERY = `
query PostsByTag($status: PostStatus, $tag: String!, $page: Int, $pageSize: Int) {
posts(status: $status, tag: $tag, page: $page, pageSize: $pageSize) {
items {
id
title
slug
excerpt
coverImage
tags
publishedAt
author {
displayName
}
}
total
page
pageSize
}
}
`;
interface TagPageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
}
export default async function TagPage({ params, searchParams }: TagPageProps) {
const { slug } = await params;
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const { posts } = await gqlFetch<{ posts: PostPage }>(
POSTS_BY_TAG_QUERY,
{ status: 'PUBLISHED', tag: slug, page, pageSize: PAGE_SIZE },
{ revalidate: 60, tags: [`tag:${slug}`] },
);
const totalPages = Math.max(1, Math.ceil(posts.total / posts.pageSize));
return (
<>
<h1>Posts tagged &ldquo;{slug}&rdquo;</h1>
{posts.items.length === 0 ? (
<p>No posts found for this tag.</p>
) : (
<>
<div className={styles.grid}>
{posts.items.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
<nav className={styles.pagination}>
{page > 1 && <Link href={`/tags/${slug}?page=${page - 1}`}>← Newer</Link>}
<span>
Page {page} of {totalPages}
</span>
{page < totalPages && <Link href={`/tags/${slug}?page=${page + 1}`}>Older →</Link>}
</nav>
</>
)}
</>
);
}
  • .titleLink { display: block; } เข้ามาแทน .card ในฐานะ class บน <Link> ด้านใน เพราะต้องทำตัวเป็น block-level element แบบเดียวกับ anchor เดี่ยวตัวเก่า เพื่อให้รูปปก, ชื่อเรื่อง และ excerpt ยัง stack และเต็มความกว้างของการ์ดเหมือนเดิม .card เองย้ายไปที่ <div> ด้านนอก ไม่เปลี่ยนอะไรอื่นเลย ดังนั้น border, padding, และสี hover จึงดูเหมือนเดิมทุกประการกับภาพหน้าจอของ Styling แม้ว่า element ข้างใต้จะเปลี่ยนจาก <a> เป็น <div> ก็ตาม
  • styles from '@/components/PostGrid.module.css' — ไฟล์ CSS Module ตัวเดียวกันเป๊ะที่ The home list สร้างไว้ import ผ่าน alias ระดับ component แทนที่จะเป็น path สัมพัทธ์ระดับ route อย่าง ../../page.module.css การใช้ไฟล์ซ้ำแบบนี้หมายความว่าการเปลี่ยน .grid หรือ .pagination อัปเดตทั้งสองหน้าพร้อมกัน โดยไม่มีสำเนาที่สองให้ต้องคอยซิงก์
  • posts(status: $status, tag: $tag, ...) คือรูปร่าง query เดียวกันเป๊ะกับ POSTS_QUERY ของ home-list.mdx บวกอาร์กิวเมนต์ที่จำเป็นอีกหนึ่งตัว — resolver PostsResolver.posts ตัวเดียวกัน, response type PostPage ตัวเดียวกัน แค่ filter ต่างกัน
  • branch สถานะว่างเปล่า (posts.items.length === 0) คือรูปธรรมของประเด็นใน Why section: หน้านี้ปฏิบัติกับ tag ที่มีจริงแต่ว่างเปล่าและ tag ที่ไม่รู้จัก (โดยนัย) เหมือนกัน เพราะ posts(tag: slug) แยกความต่างจริง ๆ ไม่ได้เลย
Terminal window
cd apps/web
npm run dev

เปิด http://localhost:3000/tags — ทุก tag จาก Verify section ของ Tags resolver ควรถูกแสดงและลิงก์ไว้ คลิกอันหนึ่งไปที่ http://localhost:3000/tags/<slug> แล้วยืนยันว่า grid แสดงแค่โพสต์ที่มี tag นั้น

ตอนนี้กลับไปที่ http://localhost:3000 (home list) หรือหน้าโพสต์แล้วคลิก tag โดยตรงบน PostCard หรือบนรายการ tag ของ Post page ควรพาไปที่หน้า tag ที่กรองแล้วตัวเดียวกัน เปิด element inspector ของ browser บนการ์ดแล้วยืนยันว่าไม่มี <a> ซ้อนอยู่ข้างใน <a> อีกตัวที่ไหนใน markup เลย — การ์ดด้านนอกเป็น <div>, บล็อกชื่อเรื่อง/รูปปก/excerpt เป็น <a> หนึ่งตัว, และแต่ละ tag เป็น <a> แยกของตัวเอง ทั้งหมดในฐานะ sibling กัน

สุดท้าย เข้า slug ที่ไม่มี tag จริงอยู่ข้างหลังเลย:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/tags/not-a-real-tag
200

200 ไม่ใช่ 404 — ยืนยันว่าหน้าเรนเดอร์ข้อความ “No posts found for this tag” แทนที่จะปฏิบัติกับ slug ที่ไม่รู้จักเป็น error

app/tags/[slug]/page.tsx เอารูปร่าง query posts ของ home-list.mdx มาใช้ซ้ำเป๊ะ บวก filter tag เข้าไป, ISR และรูปแบบการแบ่งหน้าเดียวกัน และสถานะว่างเปล่าที่ตรงไปตรงมาแทนที่การเรียก notFound() ที่ไม่มีเหตุผลรองรับพอ — posts(tag: slug) แยกความต่างระหว่าง tag จริงที่ว่างเปล่ากับพิมพ์ผิดไม่ได้ app/tags/page.tsx แสดงทุก Tag จาก query tags ที่ไม่ต้องมี guard PostCard เปลี่ยนจาก anchor ก้อนเดียวเป็น <div> ที่ห่อ <Link> ของชื่อเรื่อง/รูปปก/excerpt และ <Link> แยกต่อ tag แก้สิ่งที่จะกลายเป็น HTML แบบ nested-anchor ที่ไม่ถูกต้องทันทีที่ tag กลายเป็นสิ่งที่คลิกได้ — การเตือนความจำเล็ก ๆ ที่เป็นรูปธรรมว่า component ที่สร้างมาสำหรับ interaction pattern หนึ่งบางครั้งต้องการการปรับโครงสร้างจริง ๆ ไม่ใช่แค่เพิ่ม href เมื่อมี interaction pattern ที่สองซ้อนทับเข้ามา

ถัดไป: SEO, RSS & sitemap →