ทำเว็บ Blog ด้วย Next.js และ Strapi
[ สามารถดู 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` inside
export 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 more
export 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
- ปรับ ENV ของ url สำหรับการต่อให้ถูกต้อง