Fullstack
Membangun Blog CMS Modern dengan Next.js, Payload CMS, dan Cloudflare R2

Halo, selamat datang di blog saya. Perkenalkan saya Febri Ardiansyah, saat ini saya bekerja di sebuah perusahaan, namun disela-sela kesibukan saya, saya aktif mengembangkan web application. Kali ini saya akan membagikan tutorial membangun web application menggunakan Payload CMS.
Kenapa Payload CMS?
Saya memilih Payload CMS karena integrasinya sangat bagus dengan Next.js dan TypeScript. Selain itu Payload bersifat self-hosted sehingga saya memiliki kontrol penuh terhadap database, storage, dan deployment.
Payload juga menyediakan admin panel yang modern, hooks yang fleksibel, serta arsitektur yang cocok untuk belajar membangun aplikasi fullstack production-ready.
Setup
Pertama kamu perlu memiliki node js versi >=20 dan postgres di local computer kamu(jika kamu belum menginstall cek tutorial di youtube). Setelah memastikan semua sudah terinstall jalankan perintah dibawah ini.
#jika kamu menggunakan npm
npx create-payload-app@latest
#pnpm
pnpx create-payload-app@latestSetelah kamu menjalankan perintah diatas kamu akan di berikan beberapa pertanyaan dan isi pertanyaan tersebut dengan
#template
blank
#adaptor
postgre sqljika selesai install masuk ke code editor masing" dan jalankan npm run dev
Collection
Nah ini bagian yang menarik dan menurut saya ini superpowerfull, karena apa? yaps kita hanya mendefinisikan collection/structure table dan boom! , semua logic backend otomatis generate. Kita tidak perlu buang-buang waktu menulis CRUD secara manual karena semua sudah dihandle payload.
Namun jika kamu penasaran dimana routenya anda bisa cek di /app/(payload)/api/[...slug]/route.ts.
Categories
import { formatSlug } from '@/lib/utils'
import { CollectionConfig } from 'payload'
export const Categories: CollectionConfig = {
slug: 'categories',
admin: {
useAsTitle: 'name',
},
access: {
read: () => true,
},
hooks: {
beforeValidate: [
({ data }) => {
if (data?.name && !data?.slug) {
data.slug = formatSlug(data.name) // ini logic agar slug auto generate
}
return data
},
],
},
fields: [
{
name: 'name',
type: 'text',
required: true,
unique: true,
},
{
name: 'slug',
type: 'text',
unique: true,
required: true,
},
{
name: 'description',
type: 'textarea',
},
],
}Tags
import { formatSlug } from '@/lib/utils'
import { CollectionConfig } from 'payload'
export const Tags: CollectionConfig = {
slug: 'tags',
admin: {
useAsTitle: 'name',
},
access: {
read: () => true,
},
hooks: {
beforeValidate: [
({ data }) => {
if (data?.name && !data?.slug) {
data.slug = formatSlug(data.name) // ini logic agar slug auto generate
}
return data
},
],
},
fields: [
{
name: 'name',
type: 'text',
required: true,
unique: true,
},
{
name: 'slug',
type: 'text',
unique: true,
required: true,
},
],
}Posts
import { formatSlug } from '@/lib/utils'
import { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
},
access: {
read: () => true,
},
versions: {
drafts: true,
},
hooks: {
beforeValidate: [
({ data, req }) => {
if (data?.title && !data?.slug) {
data.slug = formatSlug(data.title) // ini logic agar slug auto generate
}
if (req.user && !data?.author) {
data!.author = req.user.id // ini logic agar author terisi oleh data user login
}
if (data?._status === 'published' && !data?.publishedAt) {
data.publishedAt = new Date().toISOString() // dan generate publishedAt saat publish di trigger
}
return data
},
],
afterDelete: [
({ doc }) => {
revalidateTag('posts', 'max')
revalidateTag(`post-${doc.slug}`, 'max')
return doc
},
],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
unique: true,
admin: {
position: 'sidebar',
},
},
{
name: 'excerpt',
type: 'textarea',
required: true,
},
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
required: true,
hasMany: false,
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
},
{
name: 'heroImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'content',
type: 'richText',
required: true,
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
admin: {
position: 'sidebar',
},
},
{
type: 'collapsible',
label: 'SEO',
fields: [
{
name: 'meta',
type: 'group',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
],
},
],
},
{
name: 'publishedAt',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayOnly',
},
},
},
],
}Utils
export const formatSlug = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')setelah kamu tulis semua, jalankan npm run generate:type.
Query
Collection sudah kita buat dan kita sudah menjalankan generate:type tugas kita sekarang buat utils untuk query posts. Karena kita menggunakan hooks untuk meng-revalidate data, fetching-nya kita cache secara agresif, artinya tidak ada time-based revalidation, cache hanya akan dibersihkan saat data berubah melalui hooks.
getPublishedPosts
Utils query untuk mengambil list posts.
import { getPayload } from 'payload'
import config from '@payload-config'
import { unstable_cache } from 'next/cache' // untuk caching di next js
const getPayloadClient = () => getPayload({ config })
export const getPublishedPosts = unstable_cache(
async (options?: { categorySlug?: string; search?: string; limit?: number; page?: number }) => {
const { categorySlug, search, limit = 12, page = 1 } = options ?? {}
const payload = await getPayloadClient()
const result = await payload.find({
collection: 'posts',
depth: 2,
limit,
page,
sort: '-publishedAt',
where: {
_status: { equals: 'published' },
...(categorySlug &&
categorySlug !== 'all' && {
'category.slug': { equals: categorySlug },
}),
...(search && {
title: { like: search },
}),
},
})
return result
},
['published-posts'],
{ tags: ['posts'] },
)getCategories
Utils query untuk mengambil Categories
import { getPayload } from 'payload'
import config from '@payload-config'
import { unstable_cache } from 'next/cache'
const getPayloadClient = () => getPayload({ config })
export const getCategories = unstable_cache(
async () => {
const payload = await getPayloadClient()
const result = await payload.find({
collection: 'categories',
limit: 100,
sort: 'name',
})
return result
},
['categories'],
{ tags: ['categories'] },
)
getPostBySlug
Utils untuk menampilkan detail post.
import { getPayload } from 'payload'
import config from '@payload-config'
import { unstable_cache } from 'next/cache'
const getPayloadClient = () => getPayload({ config })
export const getPostBySlug = unstable_cache(
async (slug: string) => {
const payload = await getPayloadClient()
const result = await payload.find({
collection: 'posts',
depth: 2,
limit: 1,
where: {
slug: { equals: slug },
_status: { equals: 'published' },
},
})
return result.docs[0] ?? null
},
[`post-by-slug-${slug}`],
{ tags: ['posts', `post-${slug}`] },
)
Penutupan
Sampai di sini kita sudah selesai membangun struktur blog menggunakan Payload CMS dan Next.js. Untuk langkah selanjutnya, kamu bisa mulai mendesain halaman list post dan halaman detail sesuai kebutuhan masing-masing.
Jika ada pertanyaan atau kamu menemukan masalah saat mengikuti tutorial ini, jangan ragu untuk menghubungi saya melalui kontak yang tersedia di bawah. Selamat mencoba! 🚀