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

Post page

apps/web/app/posts/[slug]/page.tsx — Server Component แบบ dynamic-segment ที่ดึง post(slug) พร้อม ISR เรนเดอร์ body แบบ Markdown ของโพสต์ผ่าน react-markdown และ remark-gfm แล้วแสดงรายการคอมเมนต์ที่อนุมัติแล้ว บวก generateStaticParams แบบ optional ที่ pre-render ทุก slug ที่ publish แล้วตอน build และ apps/web/components/CommentForm.tsx — Client Component ตัวแรกของบล็อกสาธารณะในคอร์สนี้ ฟอร์มเล็ก ๆ ที่เรียก addComment ผ่าน gqlFetch แล้วแสดงข้อความ “awaiting moderation” เมื่อสำเร็จ slug ที่ไม่มีอยู่จริงหรือยังเป็น draft จะเรนเดอร์ 404 ของ Next ผ่าน notFound()

post(slug) จาก Posts resolver คืนค่า Post | nullnull สำหรับ slug ที่ไม่มีอะไรตรงกันเลย นั่นเป็นแค่ครึ่งหนึ่งของ “not found” เท่านั้น เพราะ Draft → published วางไว้แล้วว่าโพสต์อาจมีอยู่จริงแต่ยัง status: 'draft' และหน้านี้ไม่มี session ที่จะแยก admin ซึ่งกำลัง preview draft ของตัวเองออกจากผู้เข้าชมทั่วไปได้ จึงปฏิบัติกับทั้งสองกรณีเหมือนกัน guard บรรทัดเดียว if (!post || post.status !== 'published') { return notFound(); } ครอบทั้ง slug ที่ไม่ตรงกับอะไรเลย และ slug ที่ตรงกับโพสต์ซึ่งยังไม่พร้อมเผยแพร่

ตัว notFound() ทำงานด้วยการโยน error ออกมา แล้ว Next ดัก throw ตัวนั้นโดยเฉพาะและเรนเดอร์ not-found.tsx ที่ใกล้ที่สุด (หรือหน้า 404 default เพราะคอร์สนี้ไม่ได้เพิ่มไฟล์กำหนดเอง) แทนสิ่งที่ฟังก์ชันจะคืนค่าตามปกติ type checker ก็เข้าใจตรงนี้ด้วย เพราะ return type ของ notFound() คือ never ทุกอย่างหลัง if block จึง narrow post จาก PostWithComments | null เหลือ PostWithComments เพียว ๆ โดยไม่ต้อง assert type เอง

ชื่อ PostWithComments เป็นความตั้งใจ ไม่ใช่แค่ความสะดวกในไฟล์เดียว comments เป็น field จริงบน GraphQL type Post เพราะ The comment model เพิ่มไว้เป็น @ResolveField() แต่ไม่ได้อยู่ใน Post interface ที่ใช้ร่วมกันใน lib/graphql.ts นั่นคือการตัดสินใจที่ถูก ไม่ใช่การมองข้าม เพราะ caller อื่น ๆ ของ gqlFetch<{ post: Post }> ในคอร์สนี้ (รวมถึงทุก PostCard ที่เรนเดอร์รายการ) แทบไม่เคยขอ comments เลย การขยาย type ที่ใช้ร่วมกันให้รวม field ที่แทบไม่มีใครต้องการ จะทำให้ type ของทุก call site อื่นบิดเบือนสิ่งที่ดึงมาจริง ๆ หน้านี้จึงประกาศ type PostWithComments = Post & { comments: Comment[] }; ในไฟล์ตัวเอง แล้วขอ selection comments { ... } ใน POST_QUERY ให้ตรงกัน ได้ type ที่ถูกต้องสำหรับ query ตัวนี้ตัวเดียว โดยไม่ไปเปลี่ยนสิ่งที่ Post ของหน้าอื่นอ้างว่ามี

react-markdown เป็นส่วนที่ต้องพูดให้ชัดเจน เพราะ “เรนเดอร์ Markdown ที่ผู้ใช้ส่งมาอย่างปลอดภัย” เป็นคำถามด้านความปลอดภัยจริง ๆ ไม่ใช่พิธีกรรม react-markdown parse Markdown เป็น AST แล้วเดินต่อเป็น React element ตรง ๆ **bold** กลายเป็น element <strong> จริงใน tree ไม่เคยกลายเป็น string ที่ส่งให้ dangerouslySetInnerHTML เลย

ผลก็คือ HTML ดิบที่ฝังอยู่ใน body ของโพสต์ (สมมติมีคนพิมพ์ <script>alert(1)</script> ลงใน Markdown editor ที่ Admin จะสร้างในภายหลัง) จะออกมาเป็นข้อความที่ escape แล้วและไม่มีพิษภัย เห็นเป็นตัวอักษร <script>alert(1)</script> ตรง ๆ บนหน้า ไม่มีการ execute เลย เพราะ react-markdown ไม่ยอมให้ raw HTML ผ่านนอกจากจะเติม plugin rehype-raw เอง ซึ่งคอร์สนี้ไม่ได้ทำ นี่คือ sanitize note ของหน้านี้: ไม่ต้องมี sanitizer library แยก (อย่าง DOMPurify) เพื่อความปลอดภัย เพราะไม่เคยแปลง Markdown เป็น HTML string ตั้งแต่ต้น ส่วน remark-gfm เพิ่ม GitHub-Flavored Markdown ทับลงไป — ตาราง, strikethrough, task list, autolinked URL — ซึ่งไม่กระทบเรื่องความปลอดภัยเลย เพราะยังเป็น AST-to-React ตลอดทางเหมือนเดิม

CommentForm เป็น Client Component ด้วยเหตุผลที่เล็กที่สุดเท่าที่ App Router รู้จัก คือต้องใช้ useState เก็บ field ฟอร์มสามตัวกับสถานะการ submit ซึ่ง Server Component ทำไม่ได้ ทุกอย่างที่เหลือในหน้านี้ — ชื่อโพสต์, เนื้อหา, รายการ tag และรายการคอมเมนต์ — ยัง server-render อยู่ มีแค่ subtree ของฟอร์มที่ส่งโค้ด React ไปที่ browser เป็นขอบเขตแบบ “เลือกเข้าร่วมเฉพาะตรงที่ต้องใช้” ชุดเดียวกับที่ App Router & layout วางไว้ให้ admin dashboard

พอ addComment สำเร็จ CommentForm จะสลับตัวเองเป็นข้อความ “awaiting moderation” ธรรมดา แทนที่จะพยายามแทรกคอมเมนต์ใหม่เข้าไปในรายการด้านบน เพราะไม่มีอะไรที่ถูกต้องให้แทรกเลย Moderation กำหนดไว้แล้วว่าคอมเมนต์ใหม่เป็น PENDING เสมอ และ selection comments ของหน้านี้ขอเฉพาะ thread ที่อนุมัติแล้ว คอมเมนต์ที่เพิ่ง submit จึงไม่ได้อยู่ในสิ่งที่หน้านี้มีสิทธิ์แสดงในตอนนี้ ข้อความนั้นคือความจริงทั้งหมด ไม่ใช่ placeholder ของฟีเจอร์ที่ยังขาด

การเรนเดอร์แบบ AST-to-React ของ react-markdown (ที่เราใช้) เทียบกับ library แปลง Markdown-เป็น-HTML-string (เช่น marked) บวก dangerouslySetInnerHTML library แบบ string อย่าง marked มักเร็วกว่าใน benchmark ดิบ ๆ และแคชง่ายกว่าเพราะเป็น HTML string ก้อนเดียว แต่การส่ง string นั้นให้ dangerouslySetInnerHTML เท่ากับให้ React เชื่อโดยไม่ตรวจ HTML ดิบอะไรก็ตามที่ body ของโพสต์บังเอิญมี (tag <script> ที่วางเข้ามา หรือ attribute onerror บน <img>) จะ execute ใน browser ตามที่เขียนไว้เป๊ะ เว้นแต่จะมี sanitizer แยกต่างหาก (DOMPurify คือตัวเลือกมาตรฐาน) มาขัดไว้ก่อน

sanitizer ตัวนั้นจึงไม่ใช่ของเสริมสำหรับแนวทางนี้ แต่เป็นชิ้นส่วนที่รับน้ำหนักจริง เป็น dependency เพิ่มอีกตัว และเป็นอีกจุดที่ตั้งค่าผิดเมื่อไร (เผลออนุญาต tag หรือ attribute ที่ไม่ควร) ช่องโหว่ที่ควรปิดก็เปิดกลับมาทันที ส่วน react-markdown ไม่ต้องมี dependency แบบนั้นเลย เพราะไม่เคยผลิต HTML string ออกมาตั้งแต่แรก ไม่มีอะไรให้ sanitizer ต้องขัดด้วยซ้ำ ความปลอดภัยจึงมาจากโครงสร้าง ไม่ใช่จากการจำให้ได้ว่าต้องเรียกฟังก์ชันที่ถูกต้องทุกครั้ง

สร้าง apps/web/components/CommentForm.module.css:

.form {
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: 480px;
margin-top: var(--space-4);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
}
.field input,
.field textarea {
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font: inherit;
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
}
.success {
color: var(--color-muted);
font-size: var(--text-sm);
}

เพิ่ม color variable ใหม่ตัวเดียวที่ต้องใช้ ลงใน :root block ของ apps/web/app/globals.css ถัดจาก --color-primary:

--color-danger: #b91c1c;

สร้าง apps/web/components/CommentForm.tsx:

'use client';
import { useState } from 'react';
import { gqlFetch } from '@/lib/graphql';
import type { Comment } from '@/lib/graphql';
import styles from './CommentForm.module.css';
const ADD_COMMENT_MUTATION = `
mutation AddComment($postId: ID!, $input: AddCommentInput!) {
addComment(postId: $postId, input: $input) {
id
authorName
body
createdAt
}
}
`;
interface CommentFormProps {
postId: string;
}
export function CommentForm({ postId }: CommentFormProps) {
const [authorName, setAuthorName] = useState('');
const [authorEmail, setAuthorEmail] = useState('');
const [body, setBody] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await gqlFetch<{ addComment: Comment }>(ADD_COMMENT_MUTATION, {
postId,
input: { authorName, authorEmail, body },
});
setAuthorName('');
setAuthorEmail('');
setBody('');
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
} finally {
setIsSubmitting(false);
}
}
if (submitted) {
return <p className={styles.success}>Thanks — your comment is awaiting moderation.</p>;
}
return (
<form className={styles.form} onSubmit={handleSubmit}>
<label className={styles.field}>
Name
<input
value={authorName}
onChange={(event) => setAuthorName(event.target.value)}
required
minLength={2}
/>
</label>
<label className={styles.field}>
Email
<input
type="email"
value={authorEmail}
onChange={(event) => setAuthorEmail(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Comment
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
required
minLength={5}
rows={4}
/>
</label>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting…' : 'Submit comment'}
</button>
</form>
);
}

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

import Link from 'next/link';
import { notFound } from 'next/navigation';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { gqlFetch } from '@/lib/graphql';
import type { Post, Comment } from '@/lib/graphql';
import { CommentForm } from '@/components/CommentForm';
type PostWithComments = Post & { comments: Comment[] };
interface PostPageProps {
params: Promise<{ slug: string }>;
}
const POST_QUERY = `
query PostBySlug($slug: String!) {
post(slug: $slug) {
id
title
slug
body
status
tags
publishedAt
author {
displayName
}
comments {
id
authorName
body
createdAt
}
}
}
`;
const PUBLISHED_SLUGS_QUERY = `
query PublishedSlugs($status: PostStatus, $page: Int, $pageSize: Int) {
posts(status: $status, page: $page, pageSize: $pageSize) {
items {
slug
}
}
}
`;
export async function generateStaticParams() {
const { posts } = await gqlFetch<{ posts: { items: Pick<Post, 'slug'>[] } }>(
PUBLISHED_SLUGS_QUERY,
{ status: 'PUBLISHED', page: 1, pageSize: 100 },
);
return posts.items.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const { post } = await gqlFetch<{ post: PostWithComments | null }>(
POST_QUERY,
{ slug },
{ revalidate: 60, tags: [`post:${slug}`] },
);
if (!post || post.status !== 'published') {
return notFound();
}
return (
<article>
<h1>{post.title}</h1>
<p>
By {post.author.displayName}
{post.publishedAt && (
<>
{' '}
· <time dateTime={post.publishedAt}>{post.publishedAt}</time>
</>
)}
</p>
{post.tags.length > 0 && (
<ul>
{post.tags.map((tag) => (
<li key={tag}>
<Link href={`/tags/${tag}`}>{tag}</Link>
</li>
))}
</ul>
)}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body}</ReactMarkdown>
<section>
<h2>Comments ({post.comments.length})</h2>
<ul>
{post.comments.map((comment) => (
<li key={comment.id}>
<strong>{comment.authorName}</strong>{' '}
<time dateTime={comment.createdAt}>{comment.createdAt}</time>
<p>{comment.body}</p>
</li>
))}
</ul>
<CommentForm postId={post.id} />
</section>
</article>
);
}
  • type PostWithComments = Post & { comments: Comment[] } — ประกาศไว้ในไฟล์เอง ไม่ได้เพิ่มเข้าไปใน Post ที่ใช้ร่วมกันของ lib/graphql.ts ด้วยเหตุผลเดียวกับที่ Why section ด้านบนอธิบายไว้ คือไม่มี caller อื่นต้องใช้ field นี้
  • generateStaticParams pre-render ทุก slug ที่คืนค่ามาตอน build แต่ไม่ได้ปิดตาย slug อื่น การขอ slug ที่ฟังก์ชันนี้ไม่ได้คืนค่ายังใช้งานได้ปกติ Next จะ fallback ไปเรนเดอร์สด ๆ ตอน request แรกแล้วแคชผลลัพธ์ไว้ใต้กฎ revalidate: 60 เดียวกัน แทนที่จะตอบ 404 fallback นี้เป็น default อยู่แล้ว (dynamicParams เป็น true เว้นแต่หน้าจะตั้งเป็น false เอง) และหน้านี้ไม่ได้ตั้ง โพสต์ที่ publish หลัง build ล่าสุดจึงเข้าถึงได้ทันที แค่ไม่ได้ build ไว้ล่วงหน้าเท่านั้น
  • pageSize: 100 ใน generateStaticParams เป็นขีดจำกัดจริงที่ตั้งใจบอกไว้ ไม่ใช่การมองข้าม — มีแค่ 100 slug ที่ publish แล้วตัวแรกเท่านั้นที่ถูก prerender ตอน build บล็อกขนาดคอร์สนี้ไม่มีวันเข้าใกล้จำนวนนั้น deployment จริงที่มี archive ใหญ่กว่านี้จะต้องวนอ่านทุกผลลัพธ์ของ posts ระหว่าง build ไม่ใช่สมมติว่าการเรียกครั้งเดียวคืนทุกอย่าง
  • ลิงก์เหล่านี้ชี้ไปที่ /tags/<slug> ที่เป็น route ที่ Tag pages จะสร้างต่อไป จนกว่าจะถึงตอนนั้น คลิกแล้วจะได้ 404 เป็นแพตเทิร์น “อ้างอิงล่วงหน้าก่อนปลายทางจะมีจริง” แบบเดียวกับลิงก์ nav Admin ใน App Router & layout
  • return notFound(); แทนที่จะเป็น notFound(); เฉย ๆ — ทำงานเหมือนกันทุกประการ เพราะ notFound() โยน error ไม่ว่าจะเขียนแบบไหนและไม่เคยคืนค่าเลย แต่การเขียน return นำหน้าอ่านแล้วสื่อถึงการ “หยุดตรงนี้” ที่ตั้งใจไว้ ตรงกับ pattern ที่ตัวอย่างของ Next เองใช้
Terminal window
cd apps/web
npm run dev

เปิด http://localhost:3000/posts/<slug ที่ publish แล้ว> — ชื่อเรื่อง, ผู้เขียน, วันที่, ลิงก์ tag, เนื้อหา Markdown ที่เรนเดอร์แล้ว, และคอมเมนต์ที่อนุมัติแล้วจาก Verify section ของ Moderation ควรปรากฏทั้งหมด ยืนยันว่า Markdown เรนเดอร์จริง ไม่ใช่ข้อความดิบ ด้วยการเช็คว่าคำที่เป็น **bold** ใน body ของโพสต์แสดงเป็นตัวหนา ไม่ใช่มีเครื่องหมายดอกจันติดอยู่

ตอนนี้มาพิสูจน์ว่า notFound() ทำงานครบทั้งสองกรณีที่ควรดักได้:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/posts/does-not-exist
404

สร้างโพสต์ผ่าน Sandbox แล้วปล่อยให้เป็น draft (อย่าเรียก publishPost) แล้วลองขอ slug ของโพสต์นั้นแบบเดียวกัน ควรได้ 404 เช่นกัน แม้ post(slug) ฝั่ง API จะคืนข้อมูลจริงกลับมาก็ตาม (admin ที่ query API โดยตรงยังได้ draft กลับมา หน้านี้คือชั้นที่ตัดสินว่าไซต์สาธารณะไม่ควรได้)

สุดท้าย submit CommentForm บนโพสต์ที่ publish แล้ว: กรอกชื่อ, อีเมลที่ถูกต้อง, และคอมเมนต์ 5+ ตัวอักษร แล้ว submit ฟอร์มควรสลับตัวเองเป็น “Thanks — your comment is awaiting moderation” และ reload หน้าแล้วต้องไม่เห็นคอมเมนต์ใหม่นั้น ยืนยันว่าลงเป็น PENDING เหมือนทุกคอมเมนต์ที่ The comment model สร้าง

app/posts/[slug]/page.tsx ดึง post(slug) พร้อม ISR เรียก notFound() ทั้งสำหรับ slug ที่ไม่มีอยู่จริงและที่เป็น draft และเรนเดอร์ body แบบ Markdown ผ่าน react-markdown บวก remark-gfm — ปลอดภัยด้วยโครงสร้างเอง ไม่ต้องมี HTML string และไม่ต้องมี sanitizer เพราะ HTML ดิบใน body ของโพสต์เรนเดอร์เป็นข้อความไม่มีพิษภัยแทนที่จะ execute generateStaticParams pre-build 100 slug ที่ publish แล้วตัวแรกตอน build โดย default true ของ dynamicParams ครอบคลุมทุกอย่างที่ publish หลังจากนั้นแบบ on demand CommentForm คือ Client Component ตัวแรกของบล็อกสาธารณะ จำกัดขอบเขตไว้เฉพาะ subtree ที่ต้องการ useState เรียก addComment ผ่าน gqlFetch และแสดงข้อความ “awaiting moderation” แทนที่จะเป็นคอมเมนต์ที่แสดงแล้วจะไม่ถูกต้อง เพราะคอมเมนต์ใหม่เป็น PENDING เสมอ และหน้านี้เรนเดอร์แค่ thread ที่อนุมัติแล้วเท่านั้น

ถัดไป: Tag pages →