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.
Image credit: Photo by @altumcode on Unsplash
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.Welcome to Tech Exploration, where Ketryon tests innovative tools to power modern solutions. In this edition, we dive into building headless e-commerce storefronts with Shopify Hydrogen.
Welcome to Tech Exploration, where Ketryon tests cutting-edge tools to power modern solutions. In this edition, we dive into the Model Context Protocol (MCP).