วิธีสร้าง personal website ของตัวเองด้วย Next.js Tailwind และ Contentlayer
เขียน 12 สิงหาคม 2023
สารบัญ
เกริ่นนํา
สวัสดีครับ ในบทความแรกนี้ผมจะพาทุกคนไปดูวิธีการสร้างเว็บส่วนตัวของตัวเองในกรณีนี้จะเป็นเว็บบล็อคซึ่งผมเชื่อว่าทุกคนสามารถเอาไปประยุกต์ทำเว็บอะไรก็ได้
โดยหลัก ๆ เราจะใช้ Language, Framework, Library ประมาณนี้
- NextJS + Typescript สำหรับทำเว็บไซต์
- ContentLayer สำหรับการแปลง Markdown File ให้เป็นหน้าเว็บไซต์ ซึ่งในส่วนนี้เราจะเขียนบทความในรูปแบบของ Markdown (.md) file แล้วใช้ ContentLayer ในการจัดการนะครับ
- และ deploy ตัวเว็บของเราบน Vercel
ในบทความนี้จะ ไม่ได้ลง เนื้อหาวิธีการ
- จดโดเมน
- ผูก DNS กับ CloudFlare
- React
- NextJS
นะครับ โดยจะทำเป็นบทความอื่น ๆ ต่อไป
สร้าง NextJS project
ก่อนอื่นเลยเรามาเริ่มจากการสร้าง NextJS project ขึ้นมากันก่อน โดยวิธีที่ง่ายที่สุดคือการทำตาม Document ได้เลยครับ
มาทำไปพร้อม ๆ กันเลย
- Run คำสั่งเพื่อ scaffold NextJS
npx create-next-app@latest
scaffold คือการสร้างโครงสร้างพื้นฐานของ Application หรือพูดง่าย ๆ ภาษาชาวบ้านก็คือ วางโครง นั้นเอง ตัวอย่างเช่น t3 stack ที่เป็นการ scaffold NextJS + Prisma (database orm) + Tailwind + Auth + tRPC ครบจบในคำสั่งเดียว
- เบื้องต้นเพื่อไม่ให้งงเลือกตามนี้ไปก่อนนะครับ
- ใช้ TypeScript
- ไม่ใช้ Eslint
- ใช้ Tailwind
- ใช้ App Router
Eslint คือ ตัวเช็ค coding standard ไว้ผมเขียนอีกบทความอธิบายแล้วกัน น่าจะมีรายละเอียดเยอะพอสมควร
Tailwind คือ CSS framework ที่มีหลักการว่าจะเตรียม css class ต่าง ๆ ไว้ให้เรา โดยเราสามารถเรียกใช้ class เหล่านั้นได้ทันทีโดยที่ไม่ต้องไปประกาศ class ใหม่ ทำให้ตัว HTML และ Mark up อยู่ที่เดียวกัน ซึ่งทำให้ manage ได้ง่ายกว่า เช่น mt-4 จะหมายถึง margin-top: 1rem; เป็นต้น
App Router คือ paradigm ในการเขียน NextJs เลย โดยตัวฟีเจอร์นี้ถูกเพิ่มเข้ามาใน NextJS version 13 ซึ่งจาก Official Document แนะนำว่า app ใหม่ให้ใช้ app router ไปเลย ส่วน app เก่าที่ใช้ page (directory) ก็ใช้ไปก่อน แล้วค่อย ๆ ย้ายมานะจ้ะ
ประมาณนี้
เมื่อลง dependencies ต่าง ๆ เสร็จแล้ว เปิด directory my-blog ด้วย VS Code ก็จะเจอกับหน้าตาประมาณนี้
- แล้วก็ Run ตัวเว็บขึ้นมาด้วยคำสั่ง
npm run dev
รอสักพักแล้วเปิด Web Browser http://localhost:3000 ก็จะเห็นหน้านี้
เป็นอันจบพิธี Setup NextJS project ครับ ณ จุดนี้เราจะได้ NextJS Application ที่เป็นแบบ lean ๆ clean ๆ ไม่มีอะไรเลย ในขั้นต่อไปเราจะมาลง Library ที่จำเป็นกันครับ
Setup Contentlayer
ในขั้นนี้เราจะติดตั้ง package ที่ชื่อว่า Contentlayer กันครับ ซึ่งตัว Contentlayer คือ content preprocessor ที่จะ validates และ transforms ตัว Markdown (ทั้ง MD และ MDX) ให้เป็น type-safe JSON และเราก็สามารถเอาสิ่งที่ได้ไปแสดงผลในหน้าเว็บเก๋ ๆ เลย เพื่อให้เข้าใจมากขึ้น เดี๋ยวเรามาลองไปพร้อม ๆ กันครับ
ติดตั้ง Contentlayer
- ใน Terminal ของ NextJS ที่เราพึ่งเลือกเมื่อกี้ ให้ run คำสั่งด้านล่างเพื่อติดตั้ง contentlayer และ next-contentlayer
npm install contentlayer next-contentlayer
- ที่ root directory ของ project แก้ไขไฟล์ next.config.js
จาก
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;
เป็น
const { withContentlayer } = require("next-contentlayer");
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = withContentlayer(nextConfig);
- ที่ root directory ของ project แก้ไขไฟล์ tsconfig.json
จาก
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
เป็น
{
"compilerOptions": {
"baseUrl": ".",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".contentlayer/generated"
],
"exclude": ["node_modules"]
}
- เพิ่ม .contentlayer เข้าไปใน .gitignore
.
.
.
#contentlayer
.contentlayer
สร้าง Schema ของ Content
อันนี้จะเป็นการออกแบบโครงสร้างของ Content เรา (ต่อจากนี้ผมจะเรียก Content ว่า บทความ นะครับ) ซึ่งในบทความเราอาจจะบอกว่ามันจะมี Title, วันที่เขียน, หรือ Description ของบทความ หรือข้อมูลอื่น ๆ เป็นต้น ท่านผู้อ่านสามารถดูข้อมูลเพิ่มเติมได้ที่ Define Content Schema หรือทำตามไปพร้อม ๆ กันก็ได้ครับ
-
สร้างไฟล์ contentlayer.config.ts ที่ root ของ project directory
-
ใน contentlayer.config.ts เขียน code ด้านล่างนี้ลงไปครับ
import { defineDocumentType, makeSource } from "contentlayer/source-files";
export const Article = defineDocumentType(() => ({
name: "Article", // บทความของเราเรียกว่า Article นะ
filePathPattern: `**/*.mdx`, // กวาดทุก .mdx files
contentType: "mdx", // บอกว่าเป็น mdx นะ
fields: {
// declare fields และ type ที่จะมีในบทความ
title: { type: "string", required: true },
description: { type: "string" },
},
computedFields: {
// declare fields พิเศษที่จะเกิดขึ้นตอนกำลัง process บทความ
url: {
type: "string",
resolve: (article) => `/articles/${article._raw.flattenedPath}`,
},
},
}));
export default makeSource({
contentDirPath: "articles", // มองหาบทความใน directory articles
documentTypes: [Article],
});
หลาย ๆ ท่านอาจจะคุ้นเคยกับ md file มากกว่า เช่น README.md แต่ในที่นี้เราจะเขียนบทความเป็น mdx file กัน ซึ่ง mdx นี้เราสามารถเรียก React Component ใน markdown ได้เลย แต่ในบทความนี้จะยังไม่ได้พาทำนะครับ
computedFields ดูเพิ่มเติม computedFields เป็นตัวที่เรียกได้ว่าสร้างความสะดวกสบายอย่างมากเลยครับ เช่น ผมอาจจะเขียน Logic ให้มีการกวาดหา Header ทั้งหมดเพื่อนำมา generate เป็น table of content ได้ ถ้าสนใจไว้ผมเขียนอีกบทความสอนทำนะครับ
- เนื่องจากตอนเราสร้าง Schema เราบอกว่าจะ กวาดทุก .mdx files ใน directory articles เพราะฉนั้นอย่าลืม สร้าง directory articles ที่ root ของ project directory นะครับ
มาเขียนบทความกันเถอะ
และใน directory articles นี้ เราลองมาสร้างตัวอย่างบทความกันครับ เริ่มจากการ สร้างไฟล์ชื่อ post-01.mdx โดยใส่เนื้อหาตามนี้
---
title: This is title
description: This is description
---
This is content
field ที่เราประกาศไว้ใน Schema จะอยู่ในช่วงที่เปิดและปิดด้วยเครื่องหมาย --- ครับ ถัดจาก --- จะเป็น body (เนื้อหา) ของบทความ
ถ้าถึงขั้นตอนนี้ ผู้อ่านยังไม่ได้ run ตัว NextJs ก็สามารถใช้คำสั่ง
npm run dev
ได้เลยนะครับ
ซึ่งเราน่าจะเจอกับข้อความนี้ใน terminal
> my-blog@0.1.0 dev
> next dev
- ready started server on 0.0.0.0:3000, url: http://localhost:3000
- event compiled client and server successfully in 2.5s (20 modules)
Contentlayer config change detected. Updating type definitions and data...
Generated 1 documents in .contentlayer
- wait compiling...
- event compiled client and server successfully in 192 ms (20 modules)
สิ่งสำคัญคือต้องมีคำว่่า Generated 1 documents in .contentlayer นะครับ หมายความว่าตัว contentlayer generate บทความเราได้ ถ้าไม่เจอข้อความนี้ลองกลับไปดูแต่ละขั้นตอนนะครับว่าข้ามอะไรไปหรือเปล่า
ย้อนกลับไปดูตอนเราสร้าง Schema นะครับ เราบอกว่าจะมี fields สองตัวก็คือ title และ description โดยทั้งคู่มี type เป็น string เพื่อความเข้าใจว่า Contentlayer ช่วย validate บทความเราได้อย่างไร สามารถลองลบ title ออกได้ครับ แล้วใน terminal น่าจะฟ้อง error ประมาณนี้
File updated: post-01.mdx
Warning: Found 1 problems in 1 documents.
└── Missing required fields for 1 documents. (Skipping documents)
• "post-01.mdx" (of type "Article") is missing the following required fields:
• title: string
Generated 1 documents in .contentlayer
ประมาณนี้ครับ
ณ จุดนี้ ถ้าเราดูที่ root ของ project directory เราจะเจอ directory .contentlayer ตามรูปครับ
ลองดู .contentlayer/generated/Article/post-01.mdx.json
ครับ จะสังเกตว่าตัว Markdown ของเราทั้งก้อนถูกแปลงเป็น JSON โดย field จะถูกแยกออกเป็น key เฉพาะของตัวเองเลย และเนื้อหาทั้งหมดของ Markdown (ตัวที่อยู่ใต้เครื่องหมาย ---) จะถือเป็น body ครับ
ทำหน้าเว็บสำหรับแสดงบทความ
ในส่วนนี้จะเป็นการแก้ตัว React เพื่อให้สามารถดึงข้อมูลมาแสดงผลครับ
เคลียร์ boilerplate
เพื่อไม่ให้สับสนกับเรื่องที่ไม่เกี่ยวข้อง เรามาเคลียร์ boilerplate ออกกันก่อนครับ
- แก้ไข
globals.css
ให้เหลือแค่นี้ครับ
@tailwind base;
@tailwind components;
@tailwind utilities;
- แก้ไข
src/app/page.tsx
ให้เหลือแค่นี้ครับ
export default function Home() {
return (
<>
<h1>My Blog</h1>
</>
);
}
- เมื่อเรา run ตัว NextJs อีกครั้ง จะเจอกับหน้าเว็บที่มีเฉพาะข้อความ My Blog
แสดงรายการของบทความ
ปกติเรามักจะมีหน้าเว็บ 2 ประเภทนะครับ ประเภทแรกคือสำหรับแสดงบทความทั้งหมดที่เรามี อีกประเภทคือสำหรับแสดงบทความแต่ละบทความครับ
-
สร้าง directory
src/app/components
และสร้างไฟล์src/app/components/ArticleList.tsx
ขึ้นมาครับ -
แก้ไข
src/app/page.tsx
ให้เป็นแบบนี้ครับ
import { allArticles } from "contentlayer/generated"; // import articles ทั้งหมด (เราจะใช้เพื่อแสดงรายการบทความ)
export default function Home() {
return (
<>
<h1>My Blog</h1>
<hr className="border-1 " />
{allArticles.map(
// วน loop แสดงรายการบทความทั้งหมด
(article) => (
<>
<article key={article.title}>
<h2>{article.title}</h2>
<p>{article.description}</p>
</article>
<hr className="border-1 " />
</>
),
)}
</>
);
}
// สังเกตว่าเวลาเราพิมพ์ article. จะได้ autocomplete ของ field ที่เราสร้างไว้ใน contentlayer ออกมาเลย เช่น title
Autocomplete ในที่นี้เรียกอีกอย่างได้ว่า type Intellisense ในอนาคต เราจะเจอคำว่า Intellisense บ่อยมาก ถ้าเจอก็ให้นึกว่ามันคือ ฟีเจอร์นึงของการเขียน code แล้วกันครับ แต่ส่วนตัวผมเรียกมันว่า ใบ้โค้ด เช่น autocomplete, code hint, code suggestion, หรือ ใบ้ member list ของ object ข้อดีคือทำให้เราเขียนโปรแกรมได้ไวขึ้น ลดโอกาสผิดพลาดจากการพิมพ์ผิด หรือป้องกัน Bug ที่เกิดจากการอ้างอิงถึง member หรือค่าที่ไม่มีอยู่จริง
- เพื่อให้เห็นภาพ เราลองไปสร้างบทความเพิ่มอีกสัก 1 บทความกันครับ โดยสร้างไฟล์
articles/post-02.mdx
และใส่เนื้อหาตามนี้ครับ
---
title: This is a second post
description: This is the description of second post
---
This is content of second post
- ลองกลับมาดูหน้าเว็บใหม่อีกครั้ง จะเจอกับรายการบทความทั้ง 2 บทความที่เราสร้างไว้แล้ว
- แนะนำให้สร้าง React Component เพื่อจัดการการแสดงผลของรายการบทความ เช่น
Article
ที่รับ article เข้าไป แต่ทั้งนี้ก็แล้วแต่ความชอบของแต่ละคนครับ
แสดงบทความ
สร้างหน้าเว็บสำหรับแสดงบทความ
- เดี๋ยวเรากลับไปแก้
src/app/page.tsx
ให้สามารถคลิกเพื่อเปลี่ยนหน้ากันก่อน
import { allArticles } from "contentlayer/generated";
import Link from "next/link";
export default function Home() {
return (
<>
<h1>My Blog</h1>
<hr className="border-1 " />
{allArticles.map((article) => (
<>
<article key={article.title}>
<h2>{article.title}</h2>
<p>{article.description}</p>
<Link
className="text-blue-500"
href={`${article.url}`}
>
Read more
</Link>
</article>
<hr className="border-1 " />
</>
))}
</>
);
}
สังเกตว่าเราเรียกใช้ article.url
อ่าว แล้วเจ้า url
นี้มันมาจากไหน?
ลองกลับไปดู contentlayer.config.ts ที่เราเคยเขียนกันครับ
import { defineDocumentType, makeSource } from "contentlayer/source-files";
export const Article = defineDocumentType(() => ({
name: "Article",
filePathPattern: `**/*.mdx`,
contentType: "mdx",
fields: {
title: { type: "string", required: true },
description: { type: "string" },
},
computedFields: {
url: {
type: "string",
resolve: (article) => `/articles/${article._raw.flattenedPath}`,
},
},
}));
export default makeSource({
contentDirPath: "articles",
documentTypes: [Article],
});
มันมาจาก computedFields นั้นเอง
ขอแปะ note ด้านล่างอีกครั้งครับ
computedFields ดูเพิ่มเติม computedFields เป็นตัวที่เรียกได้ว่าสร้างความสะดวกสบายอย่างมากเลยครับ เช่น ผมอาจจะเขียน Logic ให้มีการกวาดหา Header ทั้งหมดเพื่อนำมา generate เป็น table of content ได้ ถ้าสนใจไว้ผมเขียนอีกบทความสอนทำนะครับ
- เมื่อแก้แล้วลองสังเกตตัว Link ดูครับ มันจะชี้ไปที่
/articles/post-01
และ/articles/post-02
เลย
- เอาละ ทีนี้เรามาสร้างหน้าสำหรับแสดงบทความกันครับ
สร้างไฟล์ src/app/articles/[slug]/page.tsx
และใส่ code ตามนี้
import { allArticles } from "contentlayer/generated"
import { notFound } from "next/navigation"
export default function ArticlePage({
params: { slug }, // ดึงค่าจาก path เช่น post-01
}: {
params: { slug: string }
}) {
// หา article ที่มี slug ตรงกับที่เราส่งเข้ามา
const article = allArticles.find(
(article) => article._raw.flattenedPath === slug
)
if (!article) {
notFound() // ถ้าไม่เจอก็ให้แสดงหน้า 404
}
return (
<>
<h1 className="text-3xl">{article.title}</h1>
<p className="text-gray-500">{article.description}</p>
<hr className="border-1 " />
<article>
{/* ใส่ content ของบทความ ตรงนี้เดี๋ยวเรามาแก้ไขกันต่อ */}
<p>Content Here</p>
</article>
</>
)
}
- ถึงจุดนี้ถ้าเราดูหน้าบทความ post-01 เราควรจะเห็นตามภาพนี้ครับ
- ต่อมา เรามาจัดการวิธีการแสดง content กันครับ ถ้าเราจำได้ ตัว content ของ post-01.mdx ก็คือ This is content ตามภาพด้านล่าง
---
title: This is title
description: This is description
---
This is content
- ซึ่ง Content ในส่วนนี้ เราจะเขียนเป็น Markdown สิ่งที่เราต้องการก็คือให้ตัว Contentlayer ช่วยแปลง Markdown เป็น HTML ให้เรา
โดยเราจะใช้ useMDXComponent
มาช่วยเรา เริ่มจากการสร้าง custom component ชื่อ Mdx ขึ้นมาครับ
src/components/Mdx.tsx
import { useMDXComponent } from "next-contentlayer/hooks"
type MdxProps = {
code: string
}
export function Mdx({ code }: MdxProps) {
const Component = useMDXComponent(code)
return <Component />
}
- เสร็จแล้วเรียกใช้ Mdx ในหน้าบทความของเรา
แก้ไข src/app/articles/[slug]/page.tsx
ตามนี้ครับ
import { Mdx } from "@/components/Mdx"
import { allArticles } from "contentlayer/generated"
import { notFound } from "next/navigation"
export default function ArticlePage({
params: { slug },
}: {
params: { slug: string }
}) {
const article = allArticles.find(
(article) => article._raw.flattenedPath === slug
)
if (!article) {
notFound()
}
return (
<>
<h1 className="text-3xl">{article.title}</h1>
<p className="text-gray-500">{article.description}</p>
<hr className="border-1 " />
<article>
<Mdx code={article.body.code} />
</article>
</>
)
}
- สังเกตว่าหน้าบทความของเราจะ render Markdown แล้ว
ในขั้นตอนนี้ สามารถทดลองใส่ Markdown syntax เข้าไปได้เลยครับ
เช่นเรามาลองแก้ post-01.mdx
ในส่วนของ Content จาก This is content
ให้เป็นตามนี้กัน
---
title: This is title
description: This is description
---
# This is Header
## This is also Header
List item
- Item 1
- Item 2
- Item 3
> Note
`code`
- อ่าว!! ทำไม Format มันเพี้ยน ๆ ไม่เหมือนที่เราคิดละ แก้ไขยังไงดี เราไปดูกันในหัวข้อต่อไปครับ
ทำให้ TailwindCSS รองรับ Markdown
จากปัญหาเมื่อครู่ ถ้าเราคุ้นเคยกับตัว TailwindCSS เราจะรู้ว่ามันล้างบาง (pre-flight) style พื้นฐานทั้งหมดของเว็บเราเลย เช่นปรับขนาดของ H1 ให้เป็นเหมือนข้อความปกติ
เพราะฉนั้นเราต้องป้องกันไม่ให้ TailwindCSS ล้าง style ของเรา โดยการใช้ prose
class ของ TailwindCSS ครับ
มาเริ่มกันเลย
- ติดตั้ง
@tailwindcss/typography
npm install -D @tailwindcss/typography
ดูเพิ่มเติม @tailwindcss/typography
- เพิ่ม plugin ใน
tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [require("@tailwindcss/typography")],
};
- แก้ไข
src/components/Mdx.tsx
เพิ่ม className="prose" เข้าไป จะได้ประมาณนี้ครับ
import { useMDXComponent } from "next-contentlayer/hooks"
type MdxProps = {
code: string
}
export function Mdx({ code }: MdxProps) {
const Component = useMDXComponent(code)
return (
<div className="prose">
<Component />
</div>
)
}
- เรียบร้อย!
สรุป
เป็นไงบ้างครับ ติดปัญหาอะไรตรงไหนไหม ถ้ามันไม่เป็นไปตามที่เราหวังแนะนำลองอ่านอีกทีก่อนครับ เผื่อพลาดจุดสำคัญจุดไหนไป
ในบทความนี้เราได้สร้าง NextJS แอพขึ้นมา แล้วก็ลงพวก dependencies ต่าง ๆ ที่จำเป็นต่อการใช้งาน ต่อมาเราได้ทำการออกแบบ Schema ของเนื้อหาของเรา แล้วก็เขียน Content ของเราในรูปแบบ Markdown เสร็จแล้วเราก็มีการเขียน React components/pages สำหรับการแสดงบทความของเรานั่นเอง
ขออภัยหากบทความนี้มีข้อผิดพลาดใด ๆ หากท่านมีข้อเสนอแนะบอกผมได้เลยครับ (dech.rachadech@gmail.com) ขอบคุณครับ