Website Development with KeystoneJS CMS
KeystoneJS 6 is not a SaaS platform or ready-made engine, but a code-first headless CMS: you describe data models in TypeScript, and the system automatically generates GraphQL API, Admin UI, and database schema. This makes KeystoneJS particularly strong for projects with non-standard domain logic, where flexibility matters more than initial setup speed.
When to Choose KeystoneJS
KeystoneJS fits when:
- Full control over data schema and business logic is needed
- Team is comfortable working with Node.js and TypeScript
- Custom access logic, complex computed fields, data-level hooks are required
- GraphQL API is the primary interface for frontend
Not suitable if you need hosting "out of the box" without DevOps or project requires rich UI for non-technical editors (then look at Strapi or Payload).
Project Structure
my-keystone-project/
├── keystone.ts # Entry point: DB, sessions, UI config
├── schema.ts # Aggregates all Lists
├── lists/
│ ├── Post.ts
│ ├── Author.ts
│ ├── Tag.ts
│ └── Category.ts
├── auth.ts # Authentication setup
├── migrations/ # Prisma migrations
└── frontend/ # Next.js or any other frontend
Configuration and Launch
// keystone.ts
import { config } from '@keystone-6/core';
import { lists } from './schema';
import { withAuth, session } from './auth';
export default withAuth(
config({
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL!,
enableLogging: process.env.NODE_ENV === 'development',
idField: { kind: 'uuid' },
},
lists,
session,
ui: {
// Restrict Admin UI access
isAccessAllowed: (context) => !!context.session?.data,
},
server: {
cors: {
origin: [process.env.FRONTEND_URL!],
credentials: true,
},
},
})
);
Content Modeling
// lists/Post.ts
import { list } from '@keystone-6/core';
import { allowAll, denyAll } from '@keystone-6/core/access';
import {
text, relationship, timestamp, select,
checkbox, image, document,
} from '@keystone-6/core/fields';
import { document as documentField } from '@keystone-6/fields-document';
export const Post = list({
access: {
operation: {
query: allowAll,
create: ({ session }) => !!session,
update: ({ session }) => !!session,
delete: ({ session }) => session?.data?.role === 'admin',
},
},
fields: {
title: text({ validation: { isRequired: true } }),
slug: text({
validation: { isRequired: true },
isIndexed: 'unique',
hooks: {
resolveInput: ({ resolvedData, inputData }) => {
if (inputData.title && !inputData.slug) {
return inputData.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
return resolvedData.slug;
},
},
}),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
{ label: 'Archived', value: 'archived' },
],
defaultValue: 'draft',
ui: { displayMode: 'segmented-control' },
}),
publishedAt: timestamp(),
content: documentField({
formatting: true,
links: true,
dividers: true,
layouts: [[1, 1], [1, 1, 1]],
}),
author: relationship({
ref: 'Author.posts',
ui: { displayMode: 'select' },
}),
tags: relationship({
ref: 'Tag.posts',
many: true,
ui: { displayMode: 'cards', cardFields: ['name'] },
}),
featuredImage: image({ storage: 'local_images' }),
seoTitle: text(),
seoDescription: text({ ui: { displayMode: 'textarea' } }),
},
hooks: {
beforeOperation: async ({ operation, item, resolvedData, context }) => {
if (operation === 'update' && resolvedData.status === 'published') {
resolvedData.publishedAt = new Date();
}
},
},
ui: {
listView: {
initialColumns: ['title', 'status', 'author', 'publishedAt'],
initialSort: { field: 'publishedAt', direction: 'DESC' },
},
},
});
GraphQL API: Working with Frontend
After running npx keystone dev, GraphQL Playground is available at http://localhost:3000/api/graphql.
# Query published posts with pagination
query GetPosts($skip: Int, $take: Int) {
posts(
where: { status: { equals: "published" } }
orderBy: { publishedAt: desc }
skip: $skip
take: $take
) {
id
title
slug
publishedAt
author {
name
avatar {
url
}
}
tags {
name
slug
}
}
postsCount(where: { status: { equals: "published" } })
}
In Next.js with Apollo Client or urql:
// lib/keystoneClient.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
export const keystoneClient = new ApolloClient({
link: createHttpLink({ uri: process.env.KEYSTONE_API_URL }),
cache: new InMemoryCache(),
defaultOptions: {
query: { fetchPolicy: 'network-only' },
},
});
File and Image Storage
// keystone.ts — storage configuration
storage: {
local_images: {
kind: 'local',
type: 'image',
generateUrl: (path) => `${process.env.BASE_URL}/images${path}`,
serverRoute: { path: '/images' },
storagePath: 'public/images',
},
s3_files: {
kind: 's3',
type: 'file',
bucketName: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET!,
signed: { expiry: 3600 },
},
},
Authentication and Sessions
KeystoneJS uses stateful sessions via cookie. Authentication configuration:
// auth.ts
import { createAuth } from '@keystone-6/auth';
import { statelessSessions } from '@keystone-6/core/session';
const { withAuth } = createAuth({
listKey: 'User',
identityField: 'email',
secretField: 'password',
initFirstItem: {
fields: ['name', 'email', 'password'],
},
sessionData: 'id name email role',
passwordResetLink: {
sendToken: async ({ itemId, identity, token, context }) => {
await sendPasswordResetEmail(identity, token);
},
},
});
export const session = statelessSessions({
maxAge: 60 * 60 * 24 * 30,
secret: process.env.SESSION_SECRET!,
});
Deployment
KeystoneJS deploys as regular Node.js application. Requirements: PostgreSQL or MySQL, Node.js 18+, sufficient memory (~256MB).
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npx keystone build
EXPOSE 3000
CMD ["npx", "keystone", "start"]
For Railway, Render, Fly.io — standard Node.js deploy. Must run keystone prisma migrate deploy before production start.
Development Timeline for Typical Website
| Stage | Time |
|---|---|
| Installation, DB config, basic Lists | 1 day |
| 3–5 models with relationships | 2–3 days |
| Access control and roles setup | 1 day |
| Authentication + sessions | 0.5 days |
| Next.js frontend integration | 2–3 days |
| Deployment + migrations | 0.5–1 days |
| Total for medium website | 7–10 days |
For corporate projects with complex access rights, multi-tenancy, and CI/CD — 3–5 weeks.







