ทำเว็บ Blog ด้วย Next.js และ Strapi
/ 5 min read
สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video
** เนื่องจากบทความนี้มีลักษณะเป็น hand-ons ค่อนข้างมาก จะไม่ได้ลง detail ไว้ในบทความ (บทความจะเป็นเหมือนสารบัญร่วมกันกับคลิปที่ทำ) หากท่านใดที่แวะเวียนเข้ามา สามารถดูได้ผ่าน Video นะครับ
Strapi คืออะไร ?
Ref: https://strapi.io/
Strapi คือ open-source project ที่ใช้สำหรับทำ CMS แบบ Headless โดยเป็นตัวช่วยอำนวยความสะดวกในการทำ API และทำหน้า CMS สำหรับจัดการ API ให้แบบอัตโนมัติ
ทีนี้ก่อนจะไปต่อ เราต้องเข้าใจก่อนว่า “Headless CMS” เนี่ยมันคืออะไร
CMS คือ software application ที่ใช้สำหรับจัดการ digital content ซึ่งปกติก็ใช้สำหรับจัดการเว็บที่เกี่ยวข้องกับการแสดงเนื้อหาเช่น
- Landing Page
- Blog
ทีนี้ โดยปกติ Technology ใช้ทำ CMS นั้นมันก็มี software บางประเภทที่ได้เตรียม feature สำหรับจัดการ CMS ให้อยู่แล้ว ซึ่งปกติเราจะแบ่งเป็น 3 หมวดใหญ่ๆคือ
- Traditional CMS = ทั้งส่วนแสดง Content และ ส่วนจัดการ content อย่าง CMS จะรวมกันอยู่ใน application เดียวกัน
- หมายความว่า ทั้ง Backend (ที่จัดการสร้าง / แก้ไข content) และ Frontend (ที่แสดงผล content) รวมอยู่ใน service เดียวกัน
- CMS ที่มีไอเดียนี้เช่น Wordpress, Joomla
- Headless CMS = มีแค่ส่วน CMS และ API มาให้ ที่เหลือฝั่ง Frontend ต้องไปจัดการเอง (จึงเรียกสิ่งนี้ว่า Headless)
- จึงทำให้มีความยืนหยุ่นสูงตอนทำ Frontend สำหรับการแสดงผล
- API อาจจะเตรียมมาให้ในรูปแบบของ Rest API หรือ GraphQL
- CMS ที่มีไอเดียนี้เช่น Strapi, Sanity, Wordpress Headless (*)

Ref: https://www.contentstack.com/cms-guides/headless-cms-vs-traditional-cms
เราสามารถใช้งานร่วมกับ Frontend ได้ยังไง ?
ดังนั้นเมื่อ Strapi เป็น Headless CMS เท่ากับว่า
- Content Management ทั้งหมดจัดการบน Strapi (แยกเว็บออกมาจัดการต่างหาก)
- และหากต้องการ Content จาก Strapi ไปใช้ จะสื่อสารผ่าน API ที่เป็น protocal กลางแทน
- ทำให้ทั้ง 2 Service แยกออกจากกัน = ไม่ขึ้นตรงต่อกัน ต่างคนต่างทำไป จะทำให้ไม่กระทบต่อกัน เวลามีการแก้ไขได้ (ยกเว้นแต่มีการแก้ไขส่วนของ API)
Overview feature ของ Strapi
Ref: https://docs.strapi.io/dev-docs/quick-start
มา Start project strapi + เล่นไปพร้อมๆกัน
npx create-strapi-app@latest my-project --quickstart
- เลือกเป็น quickstart = ใช้ SQLite database ซึ่งเป็น database ที่เก็บเป็น file (อำนวยความสะดวกทำให้ไม่ต้อง setup database เพิ่ม)
- เมื่อ command run เสร็จลงตาม step ให้เรียบร้อย (มันจะพาสร้าง admin คนแรก)
- สร้างเสร็จจะเจอหน้าตาประมาณนี้ออกมา

เล่น 3 แบบคือ
- แบบ Schema (Content แบบมี Schema ควบคุม)
- Single Component (เช่น landing page)
- ควบคุมการแสดงผลออกมาทั้งแบบ Authentication และไม่ Authentication
- ความเจ๋งของ Strapi ก็คือ เราจะสามารถได้ API และ Service ออกมาจากการจัดการผ่าน UI ได้เลย ! = เราไม่ต้อง code สำหรับส่วนของ API เลย
เราจะทำอะไรกันบ้าง
แบ่งทำเป็น 2 path ครับ
Strapi = CMS
- สร้าง 2 collections มาคือ
- Blog = ให้คนทั่วไปใช้
- Special Blog = เฉพาะคน Login ใช้ได้
- สร้าง 1 collection สำหรับการใช้งานเพื่อ generate landing page
Next.js (แต่ง style เร็วๆด้วย daisyUI)
- ดึงข้อมูล Blog มาแสดง
- ทำ Login เพื่อทำการเช็ค Account กับ Strapi
- ทำ Middleware เช็คว่า ต้อง Login เรียบร้อย ถึงจะดู Special Blog ได้
Deploy Strapi + Next.js ขึ้น Heroku + Vercel กัน (ให้เห็นภาพการนำไปใช้งานจริง)
** Require 1 อย่างก่อน follow ตาม = ควรดูหัวข้อ Next.js มาก่อน
เริ่มต้นทำจาก Strapi
Ref: https://docs.strapi.io/user-docs/intro
โจทย์
- สร้าง 2 collections มาคือ
- Blog = ให้คนทั่วไปใช้
- Special Blog = เฉพาะคน Login ใช้ได้
- สร้าง 1 collection สำหรับการใช้งานเพื่อ generate landing page
(อ่านตาม user guide หรือดูตามคลิปได้เลย)
Recheck Rest API
- Login
curl --location 'localhost:1337/api/auth/local' \--header 'Content-Type: application/json' \--data-raw '{ "identifier": "[email protected]", "password": "Mike1234"}'
- Register
curl --location 'localhost:1337/api/auth/local/register' \--header 'Content-Type: application/json' \--data-raw '{ "identifier": "[email protected]", "password": "Mike1234"}'
- Get Me
curl --location 'localhost:1337/api/users/me' \--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNzAxNjYwMjE2LCJleHAiOjE3MDQyNTIyMTZ9.YlcLibPvOzkPoyAvbp2UGqAQAHdKBXIPGbJIguYqf2Q'
- Get thumbnail
curl --location --globoff 'http://localhost:1337/api/blogs/1?populate[0]=thumbnail'
ต่อเข้ากับ nextjs
ที่ฝั่งของ Next.js นั้น
- เนื่องจากเป็นการต่อ API แบบ Server-side อยู่แล้ว (จากหลักการของ Server component) = ไม่ต้องเปิด CORS อะไรออกมา
- สามารถคุย API ผ่าน fetch หรือ axios ของ Server Component ได้เลย
Structure ของ next
.├── README.md├── app│ ├── blog --> สำหรับแสดงผล Blog รายหน้า│ │ └── [id]│ │ └── page.js│ ├── favicon.ico│ ├── globals.css│ ├── layout.js│ ├── login --> สำหรับทำหน้า login (ด้วย Server action)│ │ ├── action.js│ │ └── page.js│ ├── page.js --> หน้าหลัก│ └── special-blogs --> สำหรับแสดงผล special blog หลังจาก login แล้ว│ └── page.js├── jsconfig.json├── middleware.js├── next.config.js├── package-lock.json├── package.json├── postcss.config.js├── public│ ├── next.svg│ └── vercel.svg└── tailwind.config.js
เพื่อให้ project ลดจำนวน code ของการต่อ API = จะขอใช้ axios สำหรับการดึง API ออกมา
npm install axios
และทำการเพิ่ม .env.local
ไว้สำหรับการเรียกผ่าน path ของ STRAPI โดยกำหนด .env.local
เป็น
STRAPI_BASE_URL=http://127.0.0.1:1337
code หน้าหลัก
ที่ page.js
import axios from "axios";import Link from "next/link";
const fetchBlogs = async () => { try { const response = await axios.get(`${process.env.STRAPI_BASE_URL}/api/blogs`); return response.data.data; } catch (error) { console.log("error", error); return []; }};
export default async function Page() { const blogs = await fetchBlogs(); return ( <div> Home page (Update new) {blogs.map((blog, index) => ( <div className="flex gap-2" key={index}> <div>ID: {blog.id}</div> <div>{blog.attributes.title}</div> <div>{blog.attributes.description}</div> <div> <Link href={`blog/${blog.id}`}>Read more...</Link> </div> </div> ))} </div> );}
code ส่วน blog
ที่ blog/[id]/page.js
import axios from "axios";
const fetchBlog = async (id) => { try { const response = await axios.get( `${process.env.STRAPI_BASE_URL}/api/blogs/${id}?populate[0]=thumbnail`, ); return response.data.data; } catch (error) { console.log("error", error); return []; }};
export default async function Page({ params }) { const blog = await fetchBlog(params.id); console.log("blog", blog); return ( <div> <img src={`${process.env.STRAPI_BASE_URL}${blog.attributes.thumbnail.data.attributes.formats.thumbnail.url}`} /> {blog.id} {blog.attributes.description} </div> );}
code ส่วน login
- ที่
login/page.js
"use client";
import { useFormState } from "react-dom";import { login } from "./action";
export default async function Page() { const initialState = { message: null, };
const [state, formAction] = useFormState(login, initialState);
return ( <div> <form action={formAction}> <div> Email <input name="email" /> </div> <div> Password <input name="password" type="password" /> </div> <button>Login</button> <div>Message: {state?.message}</div> </form> </div> );}
- ที่
login/action.js
"use server";
import { cookies } from "next/headers";import { isRedirectError } from "next/dist/client/components/redirect";import { redirect } from "next/navigation";
import axios from "axios";
export async function login(prevState, formData) { try { const email = formData.get("email"); const password = formData.get("password");
console.log("email", email); console.log("password", password);
const response = await axios.post(`${process.env.STRAPI_BASE_URL}/api/auth/local`, { identifier: email, password, });
if (response.data.jwt) { cookies().set("token", response.data.jwt); } } catch (error) { return { message: error.message || "Failed to create" }; }
redirect("/special-blogs");}
code ส่วน middleware และ special blog
ที่ middleware.js
- ต้องมี token ก่อน
- และ token ต้องสามารถดึงข้อมูลจาก
api/users/me
ถึงจะอนุญาตให้เข้าไปได้ - ท้ายสุด เอาข้อมูล user แนบไปกับ header
import { NextResponse } from "next/server";
// This function can be marked `async` if using `await` insideexport async function middleware(request) { try { let token = request.cookies.get("token"); let response = await fetch(`${process.env.STRAPI_BASE_URL}/api/users/me`, { method: "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token.value}`, }, });
if (!response.ok) { throw new Error("Request error"); }
const payload = await response.json();
const requestHeaders = new Headers(request.headers); requestHeaders.set("user", JSON.stringify({ email: payload.email }));
response = NextResponse.next({ request: { headers: requestHeaders, }, });
return response; } catch (error) { console.log("error", error); return NextResponse.redirect(new URL("/", request.url)); }}
// See "Matching Paths" below to learn moreexport const config = { matcher: "/special-blogs/:path*",};
ที่ special-blogs/pages.js
- มีการใช้ token เพิ่มเพื่อดึงข้อมูล
import { headers, cookies } from "next/headers";
import axios from "axios";
const fetchSpecialBlogs = async () => { try { const token = cookies().get("token"); const response = await axios.get(`${process.env.STRAPI_BASE_URL}/api/special-blogs`, { headers: { Authorization: `Bearer ${token.value}`, }, });
return response.data.data; } catch (error) { console.log("error", error); return []; }};
export default async function Page() { const headersList = headers(); const user = JSON.parse(headersList.get("user")); const blogs = await fetchSpecialBlogs();
return ( <div> <div>Hello {user.email}</div> Special Blog {blogs.map((blog, index) => ( <div className="flex gap-2" key={index}> <div>ID: {blog.id}</div> <div>{blog.attributes.title}</div> <div>{blog.attributes.description}</div> </div> ))} </div> );}
และทั้งหมดก็จะได้ผลลัพธ์ตามที่เราต้องการออกมาได้
Deploy จริง
- strapi ใช้ heroku (เพราะ deploy ง่ายสุด)
- Ref: https://docs.strapi.io/dev-docs/deployment/heroku
- Package Postgres: https://elements.heroku.com/addons/heroku-postgresql
- Next.js ใช้ vercel
- deploy เหมือนหัวข้อของ Nuxt
- ปรับ ENV ของ url สำหรับการต่อให้ถูกต้อง
Reference
- OAuth คืออะไร ?มี Video
มารู้จักกับพื้นฐาน OAuth กันว่ามันคืออะไร และสิ่งที่เรากำลังทำกันอยู่คือ OAuth หรือไม่
- มารู้จัก Bun runtime และ ElysiaJS กันมี Video
มาลองเล่น BUN runtime ตัวใหม่ของ javascript และ ElysiaJS web framework ที่ใช้งานคู่กับ Bun
- รู้จักกับ Design Pattern - Structural (Part 2/3)มี Video
มาเรียนรู้รูปแบบการพัฒนา Software Design Pattern ประเภทที่สอง Structural กัน
- มาแก้ปัญหา Firestore กับปัญหาราคา Read pricing สุดจี๊ดมี Video มี Github
ในฐานะที่เป็นผู้ใช้ Firebase เหมือนกัน เรามาลองชวนคุยกันดีกว่า ว่าเราจะสามารถหาวิธีลด Pricing หรือจำนวนการ read ของ Firestore ได้ยังไงกันบ้าง