Bỏ qua đến nội dung

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.

Mộ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 /blog render 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

pnpm create payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
npx create-payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
yarn create payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
bunx --bun create-payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev

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-app vớ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ị → PostsCreate 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_URL và 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

pnpm create next-app@latest retroui-payload-blog
cd retroui-payload-blog
npx create-next-app@latest retroui-payload-blog
cd retroui-payload-blog
yarn create next-app@latest retroui-payload-blog
cd retroui-payload-blog
bunx --bun create-next-app@latest retroui-payload-blog
cd retroui-payload-blog

2) Cài TailwindCSS (nếu bạn không chọn template Tailwind)

pnpm i -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Thêm ./components/**/*.{ts,tsx}./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 retroui

Sau đó 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"] trong payload.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 read công khai. Với nội dung riêng tư, hãy thay bằng một hàm access.read kiể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 coverImage sang 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 cors và đảm bảo serverURL đã đượ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ằng next.config.js của Next.js cho phép domain của Payload trong images.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à statuspublished.

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 /blog liệ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.


← Quay lại các bài viết