跳到内容

2025年11月14日

将 Neobrutalism 与 Payload CMS 集成

用好 UI 库能大幅提速你的开发。本文将为你的下一个 React 项目盘点前 5 名。

一份实践性的端到端指南,教你把 Payload CMS 用作内容后端、把 Neobrutalism 用作前端组件套件,然后在 Next.js 里交付一个干净的、新粗野主义风格的博客列表

如果你的技术栈是 Next.js + TailwindCSS,那再合适不过。Payload 是 Next 原生的,Neobrutalism 又是 Tailwind 优先的,两者能严丝合缝地拼在一起。


你将构建什么

  • 一个带 Posts 集合的 Payload CMS 应用
  • 一个从 Payload 的 REST API 拉取文章的 Next.js 前端
  • 一个用 Neobrutalism 组件(Card、Badge、Button)渲染响应式列表的 /blog 页面

前提条件

  • Node.js 20.9+(Payload 需要 Node 20.9 或更新版本)
  • 前端用 Next.js 15+(Payload 是 Next 原生的)
  • 任意兼容的数据库:SQLite(开发)、Postgres 或 MongoDB

提示:本地开发时,SQLite 是最快的路子。部署时再切换到 Postgres/Mongo。


Part 1 — 搭建 Payload CMS

你可以把 Payload 加到现有的 Next.js 应用里,也可以脚手架出一个新的。这里我们会脚手架出一个专用的 Payload 项目,好让它的 API 保持干净、便于迁移。

1) 创建一个新的 Payload 应用

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

这会启动 Payload 及其 Admin UI(通常在 http://localhost:3000/admin)。

2) 定义一个 Posts 集合

创建 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;

把它加到主配置文件 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
  ],
});

如果你还没有 MediaUsers 集合,可以指定 blog 模板运行 create-payload-app,或者之后再为上传和认证添加简单的集合。

3) 填充几篇文章

打开 Admin UI → PostsCreate New,添加几篇 publishedAt 日期为过去时间的已发布文章。

4) 确认 REST API 正常工作

Payload 默认会在 /api/<collection> 暴露一个 REST API。

访问:

http://localhost:3000/api/posts?limit=10&sort=-publishedAt&where[status][equals]=published

你应该会看到包含文章的 JSON。

部署时,记得设置 PAYLOAD_PUBLIC_SERVER_URL 和数据库的环境变量。如果你把 Payload 和 Next.js 分开托管,别忘了为前端的来源开启 CORS


Part 2 — 搭建 Next.js + Neobrutalism 前端

我们会构建一个 Next.js 应用,消费 Payload 的 REST API,并用 Neobrutalism 的组件渲染一个博客列表。

1) 创建 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) 安装 TailwindCSS(如果你没选 Tailwind 模板)

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

./components/**/*.{ts,tsx}./app/**/*.{ts,tsx} 加到 tailwind.config.ts 的 content 里。如果安装器有要求,也把 Neobrutalism 的路径一并加上。

3) 安装 Neobrutalism

用官方安装器(CLI),或者手动安装。示例(pnpm):

pnpm add retroui

然后在需要的地方 import 组件。(如果 Neobrutalism 提供了用来复制组件的 CLI,就在这里运行它,并按提示操作。)

4) 连接 Payload(env + fetch 辅助函数)

创建 .env.local

NEXT_PUBLIC_PAYLOAD_BASE_URL=http://localhost:3000

添加一个小巧的 fetch 工具 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) 用 Neobrutalism 构建博客列表页

创建 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>
  );
}

如果你的 Neobrutalism 包暴露的 import 名称或路径不同,就相应调整 import { Card } from 'neobrutalism/card' 这些行。你也可以换成任意其他的 Neobrutalism 组件(Tabs、Input 等)。

6) 文章详情页(可选)

创建 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>
  );
}

对于富文本,用你喜欢的 Payload 渲染器(例如 @payloadcms/richtext-lexical),并据此渲染。


Part 3 — 跨域与部署注意事项

如果你的 Next.js 应用和 Payload 运行在不同的来源上:

  • 在 Payload 里开启 CORS:在 payload.config.ts 里设置 cors: ["https://your-next-app.com", "http://localhost:3000"]
  • 正确设置 serverURL(以及 PAYLOAD_PUBLIC_SERVER_URL),好让图片 URL 能被正确解析。
  • 保护草稿:上面我们允许了公开的 read。对于非公开内容,把它替换成一个检查认证/角色的 access.read 函数。

部署选项:

  • 自托管(VPS 上的 Node 应用),或者为了更现代的开发体验,部署到 VercelCloudflare
  • 快速演示用 SQLite,生产环境用 Postgres/Mongo

Part 4 — 进阶增强

  • 搜索与筛选:用 Payload 的查询参数(例如 where[title][like]),再接上 Neobrutalism 的 Input + Tabs 来做筛选。
  • 分页:REST API 会返回 totalDocslimitpage——用一个 Neobrutalism 的 Button 做出「加载更多」。
  • 图片:把 coverImage 移到专门的 Media 集合,并使用 Payload 的上传适配器。
  • 预览模式:在 Next.js 里设置一个草稿预览路由,用草稿认证的请求头来拉取数据。
  • GraphQL:Payload 也暴露了 GraphQL——如果你更喜欢带类型的查询,就用它。

排查问题

  • CORS 报错:检查 cors 配置,确认 serverURL 已经设置。
  • 图片不显示:确认上传返回的 url,以及 Next.js 的 next.config.js 是否在 images.domains 里允许了 Payload 的域名。
  • 什么都渲染不出来:检查 lib/payload.ts 里的 fetch URL。确认记录确实存在,并且 statuspublished

小结

现在你已经有了:

  • 一个通过 REST 暴露文章的 Payload CMS 应用
  • 一个用 Neobrutalism 做样式的 Next.js 站点
  • 一个列出并链接到各篇文章的 /blog

从这里出发,加上作者、分类和标签;用更多 Neobrutalism 组件来做样式;然后把它发布出去。


← 返回博客