Welcome to Tech Exploration, where Ketryon tests cutting-edge tools to power modern solutions. In this edition, we dive into building apps with Payload CMS.
At Ketryon, we’re passionate about tools that empower businesses with tailored, scalable solutions. We explored Payload CMS, a headless CMS and application framework that blends developer control with user-friendly content management. Unlike rigid platforms like WordPress, Payload lets us define content in code, integrating seamlessly with Next.js for websites, apps, and tools. We built a minimal blog platform to test its ecosystem, showcasing its potential for startups and Swedish enterprises seeking flexible, GDPR-compliant content solutions.
Payload CMS is an open-source, TypeScript-first headless CMS and application framework built with Node.js, React, and MongoDB or Postgres. Unlike WordPress, which relies on pre-built templates, Payload lets developers define content schemas in code, creating REST and GraphQL APIs and a customizable admin UI. It integrates natively with Next.js, running in the same project for a unified stack. Think of Payload as a backend builder: it’s like assembling a custom database and API with Lego bricks, tailored to your app’s needs, all within a JavaScript/TypeScript workflow. Its MIT license keeps it free for any project.
Key features include:
Payload CMS bridges developer flexibility and content editor usability, unlike traditional CMS platforms that limit customization. Its code-first approach saves time and costs for businesses and developers.
To dive into Payload CMS’s ecosystem, we built a minimal blog platform using Payload, Next.js, and TypeScript. Our goal was to create a web app where users create posts, manage categories, and access content via a public API, exploring key aspects: schema definition, admin UI customization, API integration, authentication, and deployment. The platform offers a flexible solution for content-driven businesses like blogs or startup websites.
We initialized a Next.js project with Payload
npx create-next-app@latest blog-platform --typescript cd blog-platform npm i payload @payloadcms/next @payloadcms/richtext-lexical @payloadcms/db-mongodb mongoose
Note: create-next-app
includes Next.js and React dependencies. We added Payload, its Next.js integration, Lexical rich-text editor, and MongoDB adapter. We copied Payload’s template files (payload.config.ts
, payload-types.ts
) into /src/app/(payload)
and connected to a MongoDB Atlas instance. The payload.config.ts
set up Payload:
import { buildConfig } from "payload"; import { mongooseAdapter } from "@payloadcms/db-mongodb"; import { lexicalEditor } from "@payloadcms/richtext-lexical"; import { Posts } from "./collections/Posts"; import { Categories } from "./collections/Categories"; import { Users } from "./collections/Users"; export default buildConfig({ collections: [Posts, Categories, Users], editor: lexicalEditor(), db: mongooseAdapter({ url: process.env.MONGODB_URI }), typescript: { outputFile: "./payload-types.ts" }, });
Running npm run dev
launched Next.js and Payload’s admin UI at /admin
, letting us test content creation.
Schemas define the app’s data structure, like a blueprint for content. We created two collections (Posts
, Categories
) and a Users
collection for authentication. The Posts
collection (collections/Posts.ts
) defined fields for blog posts:
import { CollectionConfig } from "payload"; export const Posts: CollectionConfig = { slug: "posts", admin: { defaultColumns: ["title", "category", "createdAt"] }, fields: [ { name: "title", type: "text", required: true }, { name: "content", type: "richText", required: true, editor: lexicalEditor(), }, { name: "category", type: "relationship", relationTo: "categories", required: true, }, { name: "createdAt", type: "date", admin: { readOnly: true } }, ], };
The Categories
collection was simpler:
import { CollectionConfig } from "payload"; export const Categories: CollectionConfig = { slug: "categories", fields: [{ name: "name", type: "text", required: true }], };
Payload generated TypeScript interfaces in payload-types.ts
, ensuring type-safe queries and rendering.
The admin UI lets editors manage content without coding. We customized it to enhance the post creation experience, adding a title preview field (collections/Posts.ts
):
import { Field } from "payload"; const TitlePreview: Field = { name: "titlePreview", type: "ui", admin: { components: { Field: ({ value }: { value?: string }) => ( <div style={{ fontSize: "1.2em", color: "#333" }}> Preview: {value || "No title entered"} </div> ), }, }, }; export const Posts: CollectionConfig = { slug: "posts", admin: { defaultColumns: ["title", "category", "createdAt"] }, fields: [ { name: "title", type: "text", required: true }, TitlePreview, { name: "content", type: "richText", required: true, editor: lexicalEditor(), }, { name: "category", type: "relationship", relationTo: "categories", required: true, }, { name: "createdAt", type: "date", admin: { readOnly: true } }, ], };
The Lexical editor provided rich-text editing, storing content as JSON for flexibility. Our custom field improved the editor’s workflow with a live preview.
Payload’s Local API fetches data within Next.js, ideal for fast server-side rendering. In app/page.tsx
, we displayed posts:
import { getPayload } from "@payloadcms/next"; import { Post } from "../../payload-types"; import { renderRichText } from "@payloadcms/richtext-lexical"; export default async function Home() { const payload = await getPayload(); const { docs: posts } = await payload.find({ collection: "posts", sort: "-createdAt", depth: 1, // Populate category }); return ( <div style={{ padding: "20px" }}> <h1>Blog Platform</h1> {posts.map((post: Post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{(post.category as { name: string })?.name}</p> <div>{renderRichText(post.content)}</div> </article> ))} </div> ); }
We used @payloadcms/richtext-lexical
to render the JSON content from the Lexical editor. The depth: 1
option populated category.name
. We also tested the REST API (/api/posts
) for external access, confirming its versatility.
We enabled user authentication for admin and editor roles (collections/Users.ts
):
import { CollectionConfig } from "payload"; export const Users: CollectionConfig = { slug: "users", auth: true, fields: [ { name: "name", type: "text", required: true }, { name: "role", type: "select", options: ["admin", "editor"], required: true, }, ], };
Payload’s JWT-based login and role-based access worked out of the box. We customized the login page (app/(payload)/admin/page.tsx)
:
import { Login } from "@payloadcms/next"; export default function AdminLogin() { return ( <div style={{ padding: "40px", textAlign: "center" }}> <h1>Ketryon Blog Admin</h1> <Login /> </div> ); }
This secured the admin UI, limiting editors to posts and categories.
Building the blog platform taught us valuable lessons about Payload CMS’s ecosystem:
payload-types.ts
catching errors early, saving us debugging time compared to JSON-based CMS platforms.