콘텐츠로 건너뛰기

2025년 11월 14일

Neobrutalism와 Payload CMS를 연동해 블로그 목록 만들기 실전 가이드

UI 라이브러리를 쓰면 개발을 크게 가속할 수 있습니다. 이 글에서는 다음 React 프로젝트에 도움이 될 톱 5를 소개합니다.

Payload CMS를 콘텐츠 백엔드로, Neobrutalism를 프런트엔드 컴포넌트 키트로 연결하고, Next.js에서 깔끔한 네오브루탈리즘 블로그 목록을 출시하기까지를 실전으로 처음부터 끝까지 훑는 end-to-end 가이드입니다.

스택이 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 네이티브)
  • 호환되는 아무 DB: SQLite(개발용), Postgres, 또는 MongoDB

팁: 로컬 개발에서 가장 손쉬운 길은 SQLite입니다. 배포할 때 Postgres/Mongo로 전환하세요.


Part 1 — Payload CMS 세팅하기

Payload는 기존 Next.js 앱에 추가할 수도, 새로 scaffold할 수도 있습니다. 여기서는 API를 깔끔하고 이식성 있게 유지하기 위해, 전용 Payload 프로젝트를 scaffold하겠습니다.

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를 공개합니다.

다음 URL에 접속합니다:

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

게시물이 담긴 JSON이 보일 것입니다.

배포할 때는 PAYLOAD_PUBLIC_SERVER_URL과 DB 환경 변수를 설정합니다. Payload를 Next.js와 별도로 호스팅한다면, 프런트엔드 오리진에 대해 CORS를 활성화하세요.


Part 2 — Next.js + Neobrutalism 프런트엔드 세팅하기

Payload의 REST API를 이용하고, Neobrutalism 컴포넌트로 블로그 목록을 렌더링하는 Next.js 앱을 구축합니다.

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

tailwind.config.ts의 content에 ./components/**/*.{ts,tsx}./app/**/*.{ts,tsx}를 추가합니다. 인스톨러가 요구한다면 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.tscors: ["https://your-next-app.com", "http://localhost:3000"]를 설정합니다.
  • 이미지 URL이 올바르게 해석되도록 serverURL(및 PAYLOAD_PUBLIC_SERVER_URL)을 정확히 설정합니다.
  • 초안을 보호합니다: 위에서는 공개 read를 허용했습니다. 비공개 콘텐츠라면, 인증/역할을 확인하는 access.read 함수로 교체하세요.

배포 선택지:

  • 셀프 호스팅(VPS 위의 Node 앱)을 하거나, 모던한 개발 경험을 위해 Vercel/Cloudflare에 배포합니다.
  • 손쉬운 데모에는 SQLite를, 프로덕션에는 Postgres/Mongo를 쓰세요.

Part 4 — 확장

  • 검색과 필터: Payload의 쿼리 파라미터(예: where[title][like])를 쓰고, Neobrutalism의 Input + Tabs를 연결해 필터링합니다.
  • 페이지네이션: REST API는 totalDocs, limit, page를 반환하므로, Neobrutalism Button으로 "더 불러오기"를 만듭니다.
  • 이미지: coverImage를 전용 Media 컬렉션으로 옮기고, Payload의 업로드 어댑터를 씁니다.
  • 프리뷰 모드: 초안용 인증 헤더로 가져오는 초안 프리뷰 라우트를 Next.js에 마련합니다.
  • GraphQL: Payload는 GraphQL도 공개합니다. 타입이 붙은 쿼리를 선호한다면 활용하세요.

트러블슈팅

  • CORS 오류: cors 설정을 확인하고, serverURL이 설정되어 있는지 확인합니다.
  • 이미지가 표시되지 않음: 업로드에 대해 반환되는 url을 확인하고, Next.js의 next.config.jsimages.domains에서 Payload 도메인을 허용하는지 확인합니다.
  • 아무것도 렌더링되지 않음: lib/payload.ts의 fetch URL을 확인합니다. 레코드가 존재하고 statuspublished인지 확인하세요.

정리

이제 다음이 갖춰졌습니다:

  • REST를 통해 게시물을 공개하는 Payload CMS 앱
  • Neobrutalism로 스타일링한 Next.js 사이트
  • 개별 게시물을 목록으로 보여 주고 링크하는 /blog

여기서부터 저자, 카테고리, 태그를 추가하고, 더 많은 Neobrutalism 컴포넌트로 스타일링해서, 출시하세요.


← 블로그로 돌아가기