Okay, มาสร้างบทเรียน Next.js ร่วมกับ gray-matter เพื่อสร้างหน้าแสดงรายการบทความ (List Page) และหน้ารายละเอียดบทความ (Detail Page) จากไฟล์ Markdown กันครับ บทเรียนนี้จะเน้นความละเอียดทีละขั้นตอน
เป้าหมาย: สร้างเว็บไซต์ง่ายๆ ที่:
/): แสดงรายการบทความทั้งหมด โดยดึงข้อมูลชื่อเรื่อง, วันที่, และ slug (ส่วนหนึ่งของ URL) มาจาก Frontmatter ของไฟล์ Markdown/posts/[slug]): แสดงเนื้อหาเต็มของบทความ Markdown ที่เลือกเทคโนโลยีที่เราจะใช้:
gray-matter: Library สำหรับแยกส่วน Frontmatter (ข้อมูล Metadata ที่เขียนในรูปแบบ YAML หรือ JSON ด้านบนสุดของไฟล์ Markdown) ออกจากเนื้อหาหลัก (Content)remark & remark-html: Library สำหรับแปลงเนื้อหา Markdown เป็น HTML (จำเป็นต้องใช้เพื่อแสดงผลบนหน้าเว็บ)ขั้นตอนที่ 1: ตั้งค่าโปรเจกต์ Next.js
สร้างโปรเจกต์ Next.js ใหม่: เปิด Terminal หรือ Command Prompt แล้วรันคำสั่ง:
npx create-next-app@latest my-markdown-blog
# หรือ yarn create next-app my-markdown-blog
cd my-markdown-blog
(เลือกใช้ TypeScript หรือ JavaScript ตามความถนัด ในตัวอย่างนี้จะใช้ JavaScript เป็นหลัก แต่แนวคิดเหมือนกัน)
ติดตั้ง Dependencies ที่จำเป็น:
npm install gray-matter remark remark-html
# หรือ yarn add gray-matter remark remark-html
ขั้นตอนที่ 2: เตรียมไฟล์ Markdown สำหรับบทความ
สร้างโฟลเดอร์สำหรับเก็บไฟล์ Markdown:
ใน root directory ของโปรเจกต์ (my-markdown-blog) ให้สร้างโฟลเดอร์ชื่อ posts (หรือชื่ออื่นตามต้องการ)
my-markdown-blog/
├── posts/
├── pages/
├── public/
├── styles/
└── ... (ไฟล์อื่นๆ)
สร้างไฟล์ Markdown ตัวอย่าง:
สร้างไฟล์ .md 2-3 ไฟล์ในโฟลเดอร์ posts เช่น:
posts/first-post.md:
---
title: 'โพสต์แรกของฉัน'
date: '2023-10-27'
author: 'Your Name'
---
นี่คือเนื้อหาของ **โพสต์แรก** ของฉัน ยินดีต้อนรับ!
* รายการที่ 1
* รายการที่ 2
ลองใช้ Markdown ดูสิ
posts/second-post.md:
---
title: 'เรียนรู้ Next.js และ Markdown'
date: '2023-10-28'
author: 'Another Author'
---
## หัวข้อรอง
เรากำลังเรียนรู้วิธีใช้ `gray-matter` เพื่อแยก frontmatter และใช้ `remark` เพื่อแปลง Markdown เป็น HTML
```javascript
console.log('Hello, Markdown!');
posts/yet-another-post.md:
---
title: 'โพสต์ที่สามเกี่ยวกับอะไรดี'
date: '2023-10-26'
author: 'Your Name'
---
เนื้อหาโพสต์ที่สาม... ลองใส่รูปภาพดูไหม (สมมติว่ามีรูปใน `public/images`)

สำคัญ:
--- ด้านบนสุด คือ Metadata ของบทความ เราจะใช้ gray-matter ดึงข้อมูลส่วนนี้ (เช่น title, date)first-post.md) จะถูกใช้เป็น slug สำหรับ URL ของหน้ารายละเอียด (เช่น /posts/first-post)ขั้นตอนที่ 3: สร้าง Library Function สำหรับอ่านข้อมูล Markdown
เพื่อไม่ให้โค้ดซ้ำซ้อน เราจะสร้างฟังก์ชันกลางสำหรับอ่านและประมวลผลไฟล์ Markdown
สร้างโฟลเดอร์ lib: ใน root directory ของโปรเจกต์
สร้างไฟล์ lib/posts.js:
// lib/posts.js
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
// หา path ไปยังโฟลเดอร์ posts
const postsDirectory = path.join(process.cwd(), 'posts');
export function getSortedPostsData() {
// อ่านชื่อไฟล์ทั้งหมดใน /posts
const fileNames = fs.readdirSync(postsDirectory);
const allPostsData = fileNames.map((fileName) => {
// ลบ ".md" ออกจากชื่อไฟล์เพื่อใช้เป็น id (slug)
const id = fileName.replace(/\.md$/, '');
// อ่านไฟล์ markdown เป็น string
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
// ใช้ gray-matter เพื่อแยก frontmatter และ content
const matterResult = matter(fileContents);
// รวม data จาก frontmatter กับ id
return {
id,
...(matterResult.data as { title: string; date: string }), // Cast type ถ้าใช้ TS
};
});
// เรียงลำดับโพสต์ตามวันที่ (ล่าสุดก่อน)
return allPostsData.sort((a, b) => {
if (a.date < b.date) {
return 1;
} else {
return -1;
}
});
}
export function getAllPostIds() {
// ฟังก์ชันนี้จะคืนค่า array ของ id (slug) ทั้งหมด
// สำหรับใช้ใน getStaticPaths
const fileNames = fs.readdirSync(postsDirectory);
// คืนค่า array หน้าตาแบบนี้:
// [
// { params: { id: 'first-post' } },
// { params: { id: 'second-post' } }
// ]
return fileNames.map((fileName) => {
return {
params: {
id: fileName.replace(/\.md$/, ''),
},
};
});
}
export async function getPostData(id: string) {
// ฟังก์ชันนี้จะคืนข้อมูลทั้งหมดของโพสต์ที่ระบุ id (slug)
// รวมถึงเนื้อหาที่แปลงเป็น HTML แล้ว
const fullPath = path.join(postsDirectory, `${id}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
// ใช้ gray-matter แยก frontmatter
const matterResult = matter(fileContents);
// ใช้ remark แปลง markdown content เป็น HTML
const processedContent = await remark()
.use(html)
.process(matterResult.content);
const contentHtml = processedContent.toString();
// รวม data, id, และ contentHtml
return {
id,
contentHtml,
...(matterResult.data as { title: string; date: string }), // Cast type ถ้าใช้ TS
};
}
อธิบายโค้ดใน lib/posts.js:
fs, path: Module ของ Node.js สำหรับทำงานกับ File System และ Pathmatter: ฟังก์ชันจาก gray-matter สำหรับ parse ไฟล์ Markdownremark, remark-html: ใช้สำหรับแปลง Markdown เป็น HTMLpostsDirectory: เก็บ path เต็มไปยังโฟลเดอร์ postsgetSortedPostsData():
postsDirectoryid (slug) จากชื่อไฟล์matter() แยก data (frontmatter) และ contentid และข้อมูลจาก datadategetAllPostIds():
params ซึ่งข้างในมี id (slug) ของแต่ละโพสต์ รูปแบบนี้จำเป็นสำหรับ getStaticPaths ใน Next.jsgetPostData(id):
id (slug) เข้ามาidmatter() แยก data และ contentremark().use(html).process() แปลง matterResult.content (เนื้อหา Markdown) เป็น contentHtml (สตริง HTML) (ขั้นตอนนี้สำคัญมากสำหรับการแสดงผล)id, contentHtml, และข้อมูลจาก dataขั้นตอนที่ 4: สร้างหน้าแสดงรายการบทความ (List Page)
แก้ไขไฟล์ pages/index.js (หรือ pages/index.tsx ถ้าใช้ TypeScript)
// pages/index.js
import Head from 'next/head';
import Link from 'next/link';
import { getSortedPostsData } from '../lib/posts'; // Import ฟังก์ชันที่เราสร้าง
import styles from '../styles/Home.module.css'; // หรือใช้ CSS Modules/Tailwind
// ฟังก์ชันนี้จะรันตอน build time บน server
export async function getStaticProps() {
const allPostsData = getSortedPostsData(); // เรียกใช้ฟังก์ชันดึงข้อมูลโพสต์
return {
props: {
allPostsData, // ส่งข้อมูลโพสต์ไปเป็น props ให้ component
},
};
}
// Component หลักของหน้า Home
export default function Home({ allPostsData }) { // รับ props ที่ส่งมาจาก getStaticProps
return (
<div className={styles.container}>
<Head>
<title>My Markdown Blog</title>
<meta name="description" content="Blog สร้างด้วย Next.js และ Markdown" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
ยินดีต้อนรับสู่ Blog ของฉัน
</h1>
<section className={`${styles.grid} ${styles.blogList}`}> {/* เพิ่ม class จัดสไตล์ */}
<h2>บทความล่าสุด</h2>
<ul>
{allPostsData.map(({ id, date, title }) => (
<li key={id} className={styles.listItem}>
<Link href={`/posts/${id}`}>
<a>{title}</a>
</Link>
<br />
<small className={styles.lightText}>
{/* อาจจะต้อง format date เพิ่มเติม */}
{date}
</small>
</li>
))}
</ul>
</section>
</main>
</div>
);
}
อธิบาย pages/index.js:
getStaticProps:
getSortedPostsData() จาก lib/posts.js เพื่อดึงข้อมูลโพสต์ทั้งหมดที่เรียงลำดับแล้วreturn { props: { allPostsData } }: ข้อมูลที่ return ใน props จะถูกส่งไปให้ Home componentHome Component:
allPostsData มาจาก props.map() เพื่อวนลูปสร้างรายการ (<li>) ของแต่ละโพสต์<Link href={/posts/${id}}> ของ Next.js เพื่อสร้างลิงก์ไปยังหน้ารายละเอียดของแต่ละโพสต์ URL จะเป็น /posts/first-post, /posts/second-post เป็นต้นtitle และ date ของแต่ละโพสต์ขั้นตอนที่ 5: สร้างหน้ารายละเอียดบทความ (Detail Page) - Dynamic Routes
สร้างโฟลเดอร์ posts ภายใน pages: โฟลเดอร์นี้จะเก็บ Dynamic Route ของเรา
my-markdown-blog/
├── lib/
├── posts/
├── pages/
│ ├── api/
│ ├── _app.js
│ ├── index.js
│ └── posts/ <-- สร้างโฟลเดอร์นี้
│ └── [id].js <-- สร้างไฟล์นี้ (หรือ [slug].js)
├── public/
├── styles/
└── ...
สร้างไฟล์ pages/posts/[id].js: (หรือ [slug].js ถ้าต้องการใช้ชื่อ slug) ชื่อไฟล์ใน [] บ่งบอกว่านี่คือ Dynamic Route Segment
// pages/posts/[id].js
import Head from 'next/head';
import Link from 'next/link';
import { getAllPostIds, getPostData } from '../../lib/posts'; // Import functions
import styles from '../../styles/Post.module.css'; // สร้างไฟล์ CSS นี้ด้วย
// ฟังก์ชันนี้จะรันตอน build time เพื่อบอก Next.js ว่ามี path อะไรบ้างที่จะต้องสร้างเป็น static page
export async function getStaticPaths() {
const paths = getAllPostIds(); // ดึง array ของ { params: { id: '...' } }
return {
paths,
fallback: false, // ถ้าเข้า path ที่ไม่มีใน paths ให้แสดงหน้า 404
// fallback: true // จะพยายาม generate หน้าใหม่ถ้ายังไม่มี (เหมาะกับข้อมูลเยอะๆ)
// fallback: 'blocking' // เหมือน true แต่จะรอจน generate เสร็จก่อนส่งให้ user
};
}
// ฟังก์ชันนี้จะรันตอน build time สำหรับ *แต่ละ* path ที่ได้จาก getStaticPaths
export async function getStaticProps({ params }) {
// params.id จะมีค่า slug ของหน้านั้นๆ (เช่น 'first-post')
const postData = await getPostData(params.id); // ดึงข้อมูลโพสต์ + contentHtml
return {
props: {
postData, // ส่งข้อมูลโพสต์ไปให้ component
},
};
}
// Component หลักของหน้ารายละเอียดโพสต์
export default function Post({ postData }) { // รับ props จาก getStaticProps
return (
<div className={styles.container}>
<Head>
<title>{postData.title}</title>
{/* เพิ่ม Meta tags อื่นๆ ได้ตามต้องการ */}
</Head>
<article className={styles.article}>
<h1 className={styles.title}>{postData.title}</h1>
<div className={styles.meta}>
{/* แสดงวันที่ หรือ ผู้เขียน */}
Date: {postData.date}
{postData.author && ` | Author: ${postData.author}`}
</div>
<hr className={styles.separator} />
{/* แสดงเนื้อหา HTML ที่แปลงมาจาก Markdown */}
{/* สำคัญ: ใช้ dangerouslySetInnerHTML เพราะ contentHtml เป็นสตริง HTML */}
<div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
</article>
<div className={styles.backToHome}>
<Link href="/">
<a>← กลับไปหน้าแรก</a>
</Link>
</div>
</div>
);
}
อธิบาย pages/posts/[id].js:
getStaticPaths:
getStaticProps ใน Dynamic Routes ([id].js)id (หรือ slug) อะไรบ้าง ที่จะต้องสร้างเป็นหน้า Static ล่วงหน้าตอน Build TimegetAllPostIds() จาก lib/posts.js ซึ่งคืนค่า array ในรูปแบบที่ getStaticPaths ต้องการ ([{ params: { id: '...' } }, ...])fallback: false: หมายความว่าถ้าผู้ใช้เข้า URL ที่ไม่มี id อยู่ใน paths ที่เราสร้างไว้ Next.js จะแสดงหน้า 404 Not FoundgetStaticProps:
id ที่ getStaticPaths คืนค่ามา ตอน Build Time{ params }: Next.js จะส่ง object params เข้ามา ซึ่งจะมี key ตรงกับชื่อไฟล์ใน [] (ในที่นี้คือ id) ดังนั้น params.id จะมีค่าเป็น slug ของหน้านั้นๆ (เช่น 'first-post')getPostData(params.id) จาก lib/posts.js เพื่อดึงข้อมูล เฉพาะ ของโพสต์นั้น รวมถึง contentHtml ที่แปลงแล้วreturn { props: { postData } }: ส่งข้อมูล postData (title, date, contentHtml, etc.) ไปให้ Post componentPost Component:
postData มาจาก propstitle, date (และ author ถ้ามี)dangerouslySetInnerHTML={{ __html: postData.contentHtml }}: นี่คือส่วนสำคัญในการแสดงเนื้อหา HTML ที่เราได้จากการแปลง Markdown ด้วย remark. React ปกติจะไม่ render HTML ตรงๆ เพื่อป้องกัน XSS attacks แต่ในกรณีนี้เรารู้ว่า HTML มาจาก Markdown ที่เราควบคุม เราจึงใช้ prop นี้เพื่อบอก React ให้ render HTML string นั้นออกมา ต้องมั่นใจว่าแหล่งที่มาของ Markdown ปลอดภัยขั้นตอนที่ 6: เพิ่มสไตล์ (Optional แต่แนะนำ)
สร้างไฟล์ CSS เพื่อให้หน้าเว็บดูดีขึ้น
styles/Home.module.css (ตัวอย่าง):
/* styles/Home.module.css */
.container {
padding: 0 2rem;
}
.main {
min-height: 100vh;
padding: 4rem 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
text-align: center;
margin-bottom: 2rem;
}
.blogList {
width: 100%;
max-width: 800px; /* จำกัดความกว้าง */
}
.blogList h2 {
margin-bottom: 1rem;
border-bottom: 1px solid #eaeaea;
padding-bottom: 0.5rem;
}
.blogList ul {
list-style: none;
padding: 0;
}
.listItem {
margin: 1rem 0;
padding: 1rem;
border: 1px solid #eaeaea;
border-radius: 5px;
transition: background-color 0.2s ease;
}
.listItem:hover {
background-color: #fafafa;
}
.listItem a {
font-size: 1.5rem;
color: #0070f3;
text-decoration: none;
}
.listItem a:hover {
text-decoration: underline;
}
.lightText {
color: #666;
font-size: 0.9rem;
}
styles/Post.module.css (ตัวอย่าง):
/* styles/Post.module.css */
.container {
padding: 2rem;
max-width: 800px;
margin: 0 auto; /* จัดกลาง */
}
.article {
line-height: 1.6;
}
.title {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.meta {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.separator {
border: 0;
height: 1px;
background: #eaeaea;
margin: 2rem 0;
}
/* สไตล์สำหรับเนื้อหา Markdown ที่แปลงเป็น HTML */
.article h2 {
font-size: 1.8rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
.article p {
margin-bottom: 1rem;
}
.article code {
background: #f4f4f4;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: monospace;
}
.article pre code {
display: block;
padding: 1rem;
overflow-x: auto; /* ทำให้ scroll แนวนอนได้ถ้า code ยาว */
}
.article blockquote {
border-left: 3px solid #ccc;
padding-left: 1rem;
margin-left: 0;
color: #666;
}
.article ul, .article ol {
padding-left: 2rem;
margin-bottom: 1rem;
}
.article img {
max-width: 100%;
height: auto;
display: block; /* ป้องกัน space ใต้รูป */
margin: 1rem 0;
}
.backToHome {
margin-top: 3rem;
}
.backToHome a {
color: #0070f3;
text-decoration: none;
}
.backToHome a:hover {
text-decoration: underline;
}
ขั้นตอนที่ 7: ทดสอบการทำงาน
npm run dev
# หรือ yarn dev
http://localhost:3000
http://localhost:3000/posts/first-post).md ในโฟลเดอร์ posts แล้วรีเฟรชหน้าเว็บ (ใน dev mode, Next.js มักจะอัปเดตให้อัตโนมัติ)สรุปและขั้นตอนต่อไป:
ตอนนี้คุณได้สร้างเว็บ Blog พื้นฐานด้วย Next.js ที่ดึงข้อมูลจากไฟล์ Markdown โดยใช้ gray-matter และ remark ได้สำเร็จแล้ว!
สิ่งที่คุณได้เรียนรู้:
gray-matter เพื่อ parse Markdown frontmatterremark และ remark-html เพื่อแปลง Markdown content เป็น HTMLfs และ path ของ Node.js เพื่ออ่านไฟล์getStaticProps เพื่อดึงข้อมูลสำหรับ Static Generation (SSG) ในหน้า List[id].js) ร่วมกับ getStaticPaths และ getStaticProps เพื่อสร้างหน้ารายละเอียดสำหรับแต่ละบทความdangerouslySetInnerHTMLขั้นตอนต่อไปที่น่าสนใจ:
date-fns หรือ dayjs เพื่อแสดงวันที่ในรูปแบบที่อ่านง่ายขึ้น@next/mdx)หวังว่าบทเรียนนี้จะเป็นประโยชน์และละเอียดเพียงพอนะครับ! หากมีคำถามเพิ่มเติม ถามได้เลยครับ