14 tháng 11, 2025
Tích hợp Neobrutalism với Payload CMS
Các thư viện UI có thể tăng tốc đáng kể quá trình phát triển của bạn. Trong bài này, chúng ta điểm qua 5 lựa chọn tốt nhất cho dự án React kế tiếp của bạn.
Dov Azencot
@DovAzencotMột hướng dẫn thực hành, từ đầu đến cuối, để kết nối Payload CMS làm backend nội dung và Neobrutalism làm bộ thành phần front-end, rồi phát hành một danh sách blog neo-brutalism gọn gàng trong Next.js.
Kết quả rất tốt nếu stack của bạn dựa trên Next.js + TailwindCSS. Payload thuần bản địa với Next và Neobrutalism ưu tiên Tailwind, nên cả hai khớp vào nhau rất ăn ý.
Bạn sẽ xây dựng gì
- Một ứng dụng Payload CMS với một collection Posts
- Một front-end Next.js lấy các bài viết từ REST API của Payload
- Một trang
/blogrender một danh sách responsive dùng các thành phần Neobrutalism (Cards, Badges, Buttons)
Điều kiện tiên quyết
- Node.js 20.9+ (Payload yêu cầu Node 20.9 trở lên)
- Next.js 15+ cho front-end (Payload thuần bản địa với Next)
- Bất kỳ cơ sở dữ liệu tương thích nào: SQLite (dev), Postgres, hoặc MongoDB
Mẹo: Với phát triển cục bộ, SQLite là con đường nhanh nhất. Chuyển sang Postgres/Mongo khi triển khai.
Phần 1 — Thiết lập Payload CMS
Bạn có thể thêm Payload vào một ứng dụng Next.js sẵn có hoặc tạo mới một cái. Ở đây ta sẽ tạo một dự án Payload riêng để API của nó luôn gọn gàng và dễ mang đi.
1) Tạo một ứng dụng Payload mới
Lệnh này khởi động Payload và giao diện quản trị của nó (thường ở http://localhost:3000/admin).
2) Định nghĩa một collection Posts
Tạo src/collections/Posts.ts:
import { CollectionConfig } from 'payload/types';
const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'publishedAt', 'status'],
},
access: {
read: () => true, // public read access for blog
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
},
{
name: 'excerpt',
type: 'textarea',
},
{
name: 'coverImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
required: true,
},
{
name: 'publishedAt',
type: 'date',
admin: { position: 'sidebar' },
},
{
name: 'content',
type: 'richText',
},
],
};
export default Posts;Thêm nó vào cấu hình chính src/payload.config.ts:
import { buildConfig } from 'payload/config';
import Posts from './collections/Posts';
export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
admin: { user: 'users' },
collections: [
Posts,
// Media and Users collections if you need them
],
});Nếu bạn chưa có collection Media hoặc Users, hãy chạy
create-payload-appvới một template blog, hoặc thêm các collection đơn giản cho tải lên và xác thực sau.
3) Tạo sẵn vài bài viết
Mở giao diện quản trị → Posts → Create New và thêm vài bài viết đã xuất bản với ngày publishedAt trong quá khứ.
4) Xác nhận REST API hoạt động
Payload mặc định cung cấp một REST API tại /api/<collection>.
Truy cập:
http://localhost:3000/api/posts?limit=10&sort=-publishedAt&where[status][equals]=published
Bạn sẽ thấy một JSON chứa các bài viết của mình.
Khi triển khai, hãy thiết lập
PAYLOAD_PUBLIC_SERVER_URLvà các biến môi trường cơ sở dữ liệu của bạn. Nếu bạn host Payload tách khỏi Next.js, hãy bật CORS cho origin của front-end.
Phần 2 — Thiết lập front-end Next.js + Neobrutalism
Ta sẽ xây dựng một ứng dụng Next.js tiêu thụ REST API của Payload và render một danh sách blog dùng các thành phần Neobrutalism.
1) Tạo ứng dụng Next.js
2) Cài TailwindCSS (nếu bạn không chọn template Tailwind)
pnpm i -D tailwindcss postcss autoprefixer
npx tailwindcss init -pThêm ./components/**/*.{ts,tsx} và ./app/**/*.{ts,tsx} vào trường content của tailwind.config.ts. Cũng bao gồm các đường dẫn của Neobrutalism nếu trình cài đặt yêu cầu.
3) Cài Neobrutalism
Dùng trình cài đặt chính thức (CLI) hoặc cài thủ công. Ví dụ (pnpm):
pnpm add retrouiSau đó import các thành phần ở nơi bạn cần. (Nếu Neobrutalism đi kèm một CLI để sao chép thành phần, hãy chạy nó ở đây và làm theo hướng dẫn.)
4) Kết nối tới Payload (env + hàm fetch hỗ trợ)
Tạo .env.local:
NEXT_PUBLIC_PAYLOAD_BASE_URL=http://localhost:3000
Thêm một tiện ích fetch nhỏ lib/payload.ts:
export type Post = {
id: string;
title: string;
slug: string;
excerpt?: string;
coverImage?: { url: string; filename: string } | string | null;
status: 'draft' | 'published';
publishedAt?: string | null;
};
export async function getPublishedPosts(limit = 12) {
const base = process.env.NEXT_PUBLIC_PAYLOAD_BASE_URL;
const url = new URL('/api/posts', base);
url.searchParams.set('limit', String(limit));
url.searchParams.set('sort', '-publishedAt');
url.searchParams.set('where[status][equals]', 'published');
const res = await fetch(url.toString(), { next: { revalidate: 60 } });
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`);
const data = await res.json();
return data.docs as Post[];
}5) Dựng trang danh sách blog với Neobrutalism
Tạo app/blog/page.tsx (App Router):
import Image from 'next/image';
import Link from 'next/link';
import { getPublishedPosts, type Post } from '@/lib/payload';
// Example Neobrutalism components — adjust paths/names per your install
import { Card } from 'neobrutalism/card';
import { Button } from 'neobrutalism/button';
import { Badge } from 'neobrutalism/badge';
export const revalidate = 60; // ISR cadence
export default async function BlogListPage() {
const posts = await getPublishedPosts(12);
return (
<main className="container mx-auto px-4 py-10">
<h1 className="text-4xl font-extrabold mb-6">Blog</h1>
<p className="text-muted-foreground mb-10">
Latest posts from the Payload CMS backend, styled with Neobrutalism.
</p>
<section className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Card key={post.id} className="p-0 overflow-hidden">
{typeof post.coverImage === 'object' && post.coverImage?.url ? (
<div className="relative h-44 w-full">
<Image
src={post.coverImage.url}
alt={post.title}
fill
className="object-cover"
/>
</div>
) : null}
<div className="p-5 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline">Article</Badge>
{post.publishedAt ? (
<span className="text-xs opacity-70">
{new Date(post.publishedAt).toLocaleDateString()}
</span>
) : null}
</div>
<h2 className="text-2xl font-bold leading-tight">
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
{post.excerpt ? (
<p className="text-sm text-muted-foreground line-clamp-3">
{post.excerpt}
</p>
) : null}
<div className="pt-2">
<Button asChild>
<Link href={`/blog/${post.slug}`}>Read more</Link>
</Button>
</div>
</div>
</Card>
))}
</section>
</main>
);
}Nếu gói Neobrutalism của bạn dùng tên/đường dẫn import khác, hãy điều chỉnh các dòng
import { Card } from 'neobrutalism/card'cho khớp. Bạn cũng có thể thay bằng bất kỳ thành phần Neobrutalism nào khác (Tabs, Inputs, v.v.).
6) Trang chi tiết bài viết (tùy chọn)
Tạo app/blog/[slug]/page.tsx:
import Image from 'next/image';
import Link from 'next/link';
async function getPost(slug: string) {
const base = process.env.NEXT_PUBLIC_PAYLOAD_BASE_URL!;
const url = new URL('/api/posts', base);
url.searchParams.set('limit', '1');
url.searchParams.set('where[slug][equals]', slug);
const res = await fetch(url.toString(), { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to load post');
const data = await res.json();
return data.docs[0];
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) return <div className="p-10">Not found</div>;
return (
<article className="container mx-auto px-4 py-10 max-w-3xl">
<Link href="/blog" className="underline">← Back to blog</Link>
<h1 className="text-4xl font-extrabold mt-3">{post.title}</h1>
{post.coverImage?.url ? (
<div className="relative h-80 w-full my-6">
<Image src={post.coverImage.url} alt={post.title} fill className="object-cover" />
</div>
) : null}
{post.content?.root ? (
// If using Payload Lexical richText renderer, render here
<div className="prose prose-neutral dark:prose-invert">
{/* Render your rich text */}
</div>
) : (
post.excerpt ? <p className="mt-4 text-lg">{post.excerpt}</p> : null
)}
</article>
);
}Với rich text, hãy dùng trình render Payload mà bạn ưa thích (ví dụ @payloadcms/richtext-lexical) và render tương ứng.
Phần 3 — Ghi chú về cross-origin & triển khai
Nếu ứng dụng Next.js của bạn chạy ở một origin khác với Payload:
- Bật CORS trong Payload: đặt
cors: ["https://your-next-app.com", "http://localhost:3000"]trongpayload.config.ts. - Thiết lập
serverURL(vàPAYLOAD_PUBLIC_SERVER_URL) đúng cách để các URL hình ảnh phân giải được. - Bảo vệ bản nháp: ở trên ta đã cho phép
readcông khai. Với nội dung riêng tư, hãy thay bằng một hàmaccess.readkiểm tra xác thực/vai trò.
Các lựa chọn triển khai:
- Tự host (ứng dụng Node trên một VPS) hoặc triển khai lên Vercel/Cloudflare cho một DX hiện đại.
- Dùng SQLite cho các bản demo nhanh, Postgres/Mongo cho production.
Phần 4 — Nâng cấp thêm
- Tìm kiếm & bộ lọc: dùng các tham số truy vấn của Payload (ví dụ
where[title][like]) và nối một Input + Tabs của Neobrutalism để lọc. - Phân trang: REST API trả về
totalDocs,limit,page; hãy dựng một nút „Tải thêm“ với một Button của Neobrutalism. - Hình ảnh: chuyển
coverImagesang một collection Media riêng và dùng adapter tải lên của Payload. - Chế độ xem trước: thiết lập một route xem trước bản nháp trong Next.js, lấy dữ liệu với các header xác thực bản nháp.
- GraphQL: Payload cũng cung cấp GraphQL; dùng nó nếu bạn thích các truy vấn có kiểu.
Xử lý sự cố
- Lỗi CORS: kiểm tra cấu hình
corsvà đảm bảoserverURLđã được đặt. - Hình ảnh không hiện: xác nhận
urlđược trả về cho các tệp tải lên và rằngnext.config.jscủa Next.js cho phép domain của Payload trongimages.domains. - Không có gì được render: kiểm tra URL fetch trong
lib/payload.ts. Đảm bảo các bản ghi tồn tại vàstatuslàpublished.
Tóm lại
Giờ bạn đã có:
- Một ứng dụng Payload CMS cung cấp các bài viết qua REST
- Một trang Next.js được tạo kiểu bằng Neobrutalism
- Một trang
/blogliệt kê và liên kết đến từng bài viết
Từ đây, hãy thêm tác giả, danh mục và tag; tạo kiểu với nhiều thành phần Neobrutalism hơn; và đưa nó ra mắt.