KeystoneJS CMS Integration for Content Management
KeystoneJS 6 — a headless CMS and application framework in one. The schema config is written in TypeScript, automatically generating a GraphQL API, REST-like endpoints, and an administrative interface. The closest analogue by approach is Payload, but Keystone puts GraphQL at the center of architecture.
Keystone's Strengths
Automatic GraphQL schema generation from the data model saves significant manual work. Types, mutations, filters, pagination — everything appears automatically. Plus built-in support for sessions, authentication, roles — no need to build these manually.
Works with PostgreSQL and SQLite through Prisma — migrations are generated automatically.
Installation
npm create keystone-app@latest my-project
cd my-project
npm install
Or adding to an existing project:
npm install @keystone-6/core
Data Schema
// keystone.ts
import { config, list } from '@keystone-6/core'
import { allowAll, denyAll, isSignedIn } from '@keystone-6/core/access'
import {
text, relationship, password, timestamp,
select, checkbox, image, document
} from '@keystone-6/core/fields'
import { document as documentField } from '@keystone-6/fields-document'
export default config({
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL!,
idField: { kind: 'cuid' },
},
lists: {
Post: list({
access: {
operation: {
query: allowAll,
create: isSignedIn,
update: isSignedIn,
delete: isSignedIn,
},
},
fields: {
title: text({ validation: { isRequired: true } }),
slug: text({ isIndexed: 'unique' }),
content: documentField({
formatting: true,
dividers: true,
links: true,
layouts: [[1, 1], [1, 2, 1]],
}),
publishedAt: timestamp(),
status: select({
options: ['draft', 'published', 'archived'],
defaultValue: 'draft',
ui: { displayMode: 'segmented-control' },
}),
author: relationship({ ref: 'User.posts' }),
tags: relationship({ ref: 'Tag.posts', many: true }),
cover: image({ storage: 'local_images' }),
},
hooks: {
resolveInput: async ({ resolvedData, operation }) => {
if (operation === 'create' && !resolvedData.slug) {
resolvedData.slug = resolvedData.title
?.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
}
return resolvedData
},
},
}),
Tag: list({
access: allowAll,
fields: {
name: text({ isIndexed: 'unique' }),
posts: relationship({ ref: 'Post.tags', many: true }),
},
}),
User: list({
access: {
operation: {
query: isSignedIn,
create: ({ session }) => session?.data?.role === 'admin',
update: isSignedIn,
delete: ({ session }) => session?.data?.role === 'admin',
},
},
fields: {
name: text({ validation: { isRequired: true } }),
email: text({ isIndexed: 'unique', validation: { isRequired: true } }),
password: password({ validation: { isRequired: true } }),
role: select({ options: ['admin', 'editor', 'author'], defaultValue: 'author' }),
posts: relationship({ ref: 'Post.author', many: true }),
},
}),
},
session: statelessSessions({
secret: process.env.SESSION_SECRET!,
maxAge: 60 * 60 * 24 * 30,
}),
storage: {
local_images: {
kind: 'local',
type: 'image',
generateUrl: (path) => `${process.env.ASSET_BASE_URL}/images${path}`,
serverRoute: { path: '/images' },
storagePath: 'public/images',
},
},
})
GraphQL API — Query Examples
From this schema, Keystone automatically generates:
# Get published posts with tags
query {
posts(
where: { status: { equals: "published" } }
orderBy: { publishedAt: desc }
take: 10
) {
id
title
slug
publishedAt
author {
name
}
tags {
name
}
cover {
url
width
height
}
}
}
# Create a post
mutation {
createPost(data: {
title: "New Post"
content: { document: [] }
status: "draft"
author: { connect: { id: "cuid123" } }
tags: { connect: [{ id: "tag1" }, { id: "tag2" }] }
}) {
id
slug
}
}
Filtering supports complex conditions:
query {
posts(where: {
AND: [
{ status: { equals: "published" } },
{ publishedAt: { lte: "2025-01-01T00:00:00Z" } },
{ tags: { some: { name: { equals: "TypeScript" } } } }
]
}) {
id
title
}
}
Next.js Integration
Keystone can run as a separate process or be embedded in Next.js via next.config.js. For monorepo — a separate service is preferable:
// lib/keystoneClient.ts
import { GraphQLClient } from 'graphql-request'
export const keystoneClient = new GraphQLClient(
process.env.KEYSTONE_API_URL || 'http://localhost:3000/api/graphql',
{
headers: { 'x-api-key': process.env.KEYSTONE_API_KEY! },
}
)
// Typed requests via graphql-codegen
import { getSdk } from './__generated__/sdk'
export const cms = getSdk(keystoneClient)
// app/blog/[slug]/page.tsx
import { cms } from '@/lib/keystoneClient'
export default async function PostPage({ params }) {
const { post } = await cms.getPostBySlug({ slug: params.slug })
if (!post) notFound()
return <ArticleLayout post={post} />
}
export async function generateStaticParams() {
const { posts } = await cms.getAllPostSlugs()
return posts.map(p => ({ slug: p.slug }))
}
Keystone Document Field
The built-in rich text — not just a string, but a structured document (similar to Portable Text):
import { DocumentRenderer } from '@keystone-6/document-renderer'
function PostContent({ content }) {
return (
<DocumentRenderer
document={content.document}
renderers={{
block: {
paragraph: ({ children, textAlign }) => (
<p style={{ textAlign }} className="mb-4">{children}</p>
),
layout: ({ layout, children }) => (
<div className={`grid grid-cols-${layout.join('-')}`}>
{children}
</div>
),
},
inline: {
link: ({ children, href }) => (
<a href={href} className="text-blue-600 underline">{children}</a>
),
},
}}
/>
)
}
Deployment
Keystone is a Node.js process. For production:
keystone build # build
keystone start # run
On Railway, Fly.io, Render — standard dockerfile. PostgreSQL via Supabase or own instance. Environment variables: DATABASE_URL, SESSION_SECRET, ASSET_BASE_URL.
Timeline
Standard integration with 4–6 content types, authentication setup, and GraphQL client in Next.js: 6–8 days. With codegen setup, custom hooks, and S3 storage: up to 12 days.







