Febri

GitHub

Fullstack

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

Payload Blog Starter

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.

bash
#jika kamu menggunakan npm
npx create-payload-app@latest

#pnpm
pnpx create-payload-app@latest

Setelah kamu menjalankan perintah diatas kamu akan di berikan beberapa pertanyaan dan isi pertanyaan tersebut dengan

bash
#template
blank
#adaptor
postgre sql

jika 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

/collections/Categories.ts
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

/collections/Tags.ts
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

/collections/Posts.ts
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

/lib/utils.ts
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.

typescript
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

typescript
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.

typescript
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! 🚀