390 lines
12 KiB
Plaintext
390 lines
12 KiB
Plaintext
---
|
||
title: Integrate RetroUI with Payload CMS
|
||
coverImage: https://pub-5f7cbdfd9ffa4c838e386788f395f0c4.r2.dev/blogs/retroui-with-payload-cms/retroui-with-payload-cms-cover.png
|
||
description: Using UI libraries can significantly speed up your development. In this article, we will explore the top 5 for your next React project.
|
||
tags:
|
||
- react
|
||
- ui library
|
||
- top 5
|
||
author:
|
||
name: Arif Hossain
|
||
avatar: https://pub-5f7cbdfd9ffa4c838e386788f395f0c4.r2.dev/arif.jpg
|
||
x: ariflogs
|
||
status: draft
|
||
publishedAt: 14 Nov, 2025
|
||
---
|
||
|
||
|
||
|
||
A practical, end‑to‑end guide to wire up **Payload CMS** as your content backend and **RetroUI** as your front‑end component kit—then ship a clean, neo‑brutalist **Blog List** in Next.js.
|
||
|
||
> Works great if your stack is **Next.js + TailwindCSS**. Payload is Next‑native and RetroUI is Tailwind‑first, so they snap together nicely.
|
||
|
||
---
|
||
|
||
## What you’ll build
|
||
|
||
* A Payload CMS app with a **Posts** collection
|
||
* A Next.js front‑end that fetches posts from Payload’s REST API
|
||
* A `/blog` page that renders a responsive list using RetroUI components (Cards, Badges, Buttons)
|
||
|
||
---
|
||
|
||
## Prerequisites
|
||
|
||
* **Node.js 20.9+** (Payload requires Node 20.9 or newer)
|
||
* **Next.js 15+** for the front‑end (Payload is Next‑native)
|
||
* Any compatible DB: SQLite (dev), Postgres, or MongoDB
|
||
|
||
> Tip: For local development, SQLite is the quickest path. Switch to Postgres/Mongo when deploying.
|
||
|
||
---
|
||
|
||
## Part 1 — Set up Payload CMS
|
||
|
||
You can add Payload to an existing Next.js app or scaffold a new one. We’ll scaffold a dedicated Payload project so its API stays clean and portable.
|
||
|
||
### 1) Create a new Payload app
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
This starts Payload and its Admin UI (usually at `http://localhost:3000/admin`).
|
||
|
||
### 2) Define a Posts collection
|
||
|
||
Create `src/collections/Posts.ts`:
|
||
|
||
```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;
|
||
```
|
||
|
||
Add it to your main config `src/payload.config.ts`:
|
||
|
||
```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
|
||
],
|
||
});
|
||
```
|
||
|
||
> If you don’t have a **Media** or **Users** collection yet, run `create-payload-app` with a blog template or add simple collections for uploads and auth later.
|
||
|
||
### 3) Seed a few posts
|
||
|
||
Open the Admin UI → **Posts** → **Create New** and add a couple of published posts with past `publishedAt` dates.
|
||
|
||
### 4) Confirm the REST API works
|
||
|
||
Payload exposes a REST API by default at `/api/<collection>`.
|
||
|
||
Visit:
|
||
|
||
```
|
||
http://localhost:3000/api/posts?limit=10&sort=-publishedAt&where[status][equals]=published
|
||
```
|
||
|
||
You should see JSON with your posts.
|
||
|
||
> When you deploy, set `PAYLOAD_PUBLIC_SERVER_URL` and your DB env vars. If you’re hosting Payload separately from Next.js, enable **CORS** for your front‑end origin.
|
||
|
||
---
|
||
|
||
## Part 2 — Set up Next.js + RetroUI front‑end
|
||
|
||
We’ll build a Next.js app that consumes the Payload REST API and renders a Blog List using **RetroUI** components.
|
||
|
||
### 1) Create the Next.js app
|
||
|
||
```bash
|
||
npx create-next-app@latest retroui-payload-blog
|
||
cd retroui-payload-blog
|
||
```
|
||
|
||
### 2) Install TailwindCSS (if you didn’t choose the Tailwind template)
|
||
|
||
```bash
|
||
pnpm i -D tailwindcss postcss autoprefixer
|
||
npx tailwindcss init -p
|
||
```
|
||
|
||
Add `./components/**/*.{ts,tsx}` and `./app/**/*.{ts,tsx}` to `tailwind.config.ts` content. Also include RetroUI’s paths if required by the installer.
|
||
|
||
### 3) Install RetroUI
|
||
|
||
Use the official installer (CLI) or manual install. Example (pnpm):
|
||
|
||
```bash
|
||
pnpm add retroui
|
||
```
|
||
|
||
Then import components where needed. (If RetroUI ships a CLI to copy components, run it here and follow prompts.)
|
||
|
||
### 4) Connect to Payload (env + fetch helper)
|
||
|
||
Create `.env.local`:
|
||
|
||
```
|
||
NEXT_PUBLIC_PAYLOAD_BASE_URL=http://localhost:3000
|
||
```
|
||
|
||
Add a tiny fetch utility `lib/payload.ts`:
|
||
|
||
```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) Build the Blog List page with RetroUI
|
||
|
||
Create `app/blog/page.tsx` (App Router):
|
||
|
||
```tsx
|
||
import Image from 'next/image';
|
||
import Link from 'next/link';
|
||
import { getPublishedPosts, type Post } from '@/lib/payload';
|
||
|
||
// Example RetroUI components — adjust paths/names per your install
|
||
import { Card } from 'retroui/card';
|
||
import { Button } from 'retroui/button';
|
||
import { Badge } from 'retroui/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 RetroUI.
|
||
</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>
|
||
);
|
||
}
|
||
```
|
||
|
||
> If your RetroUI package exposes different import names/paths, adjust `import { Card } from 'retroui/card'` lines to match. You can also swap in any other RetroUI components (Tabs, Inputs, etc.).
|
||
|
||
### 6) Post detail page (optional)
|
||
|
||
Create `app/blog/[slug]/page.tsx`:
|
||
|
||
```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>
|
||
);
|
||
}
|
||
```
|
||
|
||
> For rich text, use your preferred Payload Renderer (e.g., @payloadcms/richtext-lexical) and render accordingly.
|
||
|
||
---
|
||
|
||
## Part 3 — Cross‑origin & deployment notes
|
||
|
||
If your Next.js app runs at a different origin than Payload:
|
||
|
||
* Enable **CORS** in Payload: set `cors: ["https://your-next-app.com", "http://localhost:3000"]` in `payload.config.ts`.
|
||
* Set `serverURL` (and `PAYLOAD_PUBLIC_SERVER_URL`) correctly so image URLs resolve.
|
||
* Protect drafts: we allowed public `read` above. For private content, replace with an `access.read` function that checks auth/roles.
|
||
|
||
Deployment options:
|
||
|
||
* **Self‑host** (Node app on a VPS) or deploy to **Vercel**/**Cloudflare** for modern DX.
|
||
* Use **SQLite** for quick demos, **Postgres/Mongo** for prod.
|
||
|
||
---
|
||
|
||
## Part 4 — Enhancements
|
||
|
||
* **Search & filters**: Use Payload’s query parameters (e.g., `where[title][like]`) and wire a RetroUI **Input** + **Tabs** to filter.
|
||
* **Pagination**: the REST API returns `totalDocs`, `limit`, `page`—build “Load more” with a RetroUI Button.
|
||
* **Images**: move `coverImage` to a dedicated **Media** collection and use Payload’s upload adapter.
|
||
* **Preview mode**: set up a draft preview route in Next.js that fetches with draft‑auth headers.
|
||
* **GraphQL**: Payload also exposes GraphQL—use it if you prefer typed queries.
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
* **CORS errors**: Check `cors` config and ensure `serverURL` is set.
|
||
* **Images not showing**: Confirm the `url` returned for uploads and that Next.js `next.config.js` allows the Payload domain under `images.domains`.
|
||
* **Nothing renders**: Inspect the fetch URL in `lib/payload.ts`. Ensure records exist and `status` is `published`.
|
||
|
||
---
|
||
|
||
## Recap
|
||
|
||
You now have:
|
||
|
||
* A Payload CMS app exposing posts over REST
|
||
* A Next.js site styled with RetroUI
|
||
* A `/blog` that lists and links to individual posts
|
||
|
||
From here, add authors, categories, and tags; style with more RetroUI components; and ship it.
|