| name | keystonejs |
| description | Builds content APIs with KeystoneJS, the Node.js headless CMS with GraphQL. Use when creating admin UIs and APIs with automatic CRUD operations, relationships, access control, and TypeScript support. |
KeystoneJS
Open-source Node.js headless CMS that auto-generates GraphQL API and Admin UI from your schema. TypeScript-first with powerful access control.
Quick Start
npm create keystone-app@latest my-app
cd my-app
npm run dev
Opens:
- Admin UI:
http://localhost:3000 - GraphQL Playground:
http://localhost:3000/api/graphql
Configuration
// keystone.ts
import { config } from '@keystone-6/core';
import { lists } from './schema';
import { withAuth, session } from './auth';
export default withAuth(
config({
db: {
provider: 'postgresql', // or 'sqlite', 'mysql'
url: process.env.DATABASE_URL!,
},
lists,
session,
ui: {
isAccessAllowed: (context) => !!context.session?.data,
},
})
);
Schema Definition
// schema.ts
import { list } from '@keystone-6/core';
import { text, timestamp, relationship, select, checkbox, password } from '@keystone-6/core/fields';
import { document } from '@keystone-6/fields-document';
export const lists = {
User: list({
fields: {
name: text({ validation: { isRequired: true } }),
email: text({
validation: { isRequired: true },
isIndexed: 'unique',
}),
password: password({ validation: { isRequired: true } }),
posts: relationship({ ref: 'Post.author', many: true }),
},
}),
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
slug: text({ isIndexed: 'unique' }),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
ui: { displayMode: 'segmented-control' },
}),
content: document({
formatting: true,
links: true,
dividers: true,
layouts: [
[1, 1],
[1, 1, 1],
],
}),
publishedAt: timestamp(),
author: relationship({
ref: 'User.posts',
ui: {
displayMode: 'cards',
cardFields: ['name', 'email'],
inlineCreate: { fields: ['name', 'email'] },
},
}),
tags: relationship({
ref: 'Tag.posts',
many: true,
ui: {
displayMode: 'select',
labelField: 'name',
},
}),
},
hooks: {
resolveInput: async ({ resolvedData, inputData }) => {
// Auto-generate slug from title
if (inputData.title && !inputData.slug) {
resolvedData.slug = inputData.title.toLowerCase().replace(/\s+/g, '-');
}
return resolvedData;
},
},
}),
Tag: list({
fields: {
name: text({ validation: { isRequired: true } }),
posts: relationship({ ref: 'Post.tags', many: true }),
},
}),
};
Field Types
import {
text,
password,
integer,
float,
decimal,
checkbox,
select,
multiselect,
timestamp,
calendarDay,
json,
relationship,
file,
image,
} from '@keystone-6/core/fields';
import { document } from '@keystone-6/fields-document';
// Text
text({ validation: { isRequired: true, length: { max: 255 } } })
// Password (hashed)
password({ validation: { isRequired: true } })
// Numbers
integer({ defaultValue: 0 })
float()
decimal({ precision: 10, scale: 2 })
// Boolean
checkbox({ defaultValue: false })
// Select
select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
})
// Multi-select
multiselect({
options: [
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' },
{ label: 'Svelte', value: 'svelte' },
],
})
// Dates
timestamp()
calendarDay()
// JSON
json()
// Relationship
relationship({
ref: 'Post.author', // Related list.field
many: false, // One or many
})
// File upload
file({ storage: 's3' })
// Image with transforms
image({ storage: 'local' })
// Rich text (Document)
document({
formatting: true,
links: true,
dividers: true,
layouts: [[1, 1]],
})
Access Control
import { list } from '@keystone-6/core';
export const lists = {
Post: list({
access: {
operation: {
query: () => true, // Anyone can query
create: ({ session }) => !!session, // Logged in only
update: ({ session }) => !!session,
delete: ({ session }) => session?.data.isAdmin, // Admin only
},
filter: {
query: ({ session }) => {
// Non-logged in users only see published
if (!session) {
return { status: { equals: 'published' } };
}
return true; // Logged in see all
},
},
item: {
update: ({ session, item }) => {
// Users can only update their own posts
if (session?.data.isAdmin) return true;
return session?.itemId === item.authorId;
},
},
},
fields: {/* ... */},
}),
};
Hooks
export const lists = {
Post: list({
hooks: {
// Before validation
resolveInput: async ({ resolvedData, inputData, item, context }) => {
// Modify data before saving
if (inputData.title) {
resolvedData.slug = inputData.title.toLowerCase().replace(/\s+/g, '-');
}
return resolvedData;
},
// Validation
validateInput: async ({ resolvedData, addValidationError }) => {
if (resolvedData.title?.length < 3) {
addValidationError('Title must be at least 3 characters');
}
},
// Before save
beforeOperation: async ({ operation, resolvedData, context }) => {
if (operation === 'create') {
// Set author to current user
resolvedData.author = { connect: { id: context.session?.itemId } };
}
},
// After save
afterOperation: async ({ operation, item, context }) => {
if (operation === 'create') {
console.log(`New post created: ${item.title}`);
// Send notification, revalidate cache, etc.
}
},
},
fields: {/* ... */},
}),
};
GraphQL API
Auto-generated based on your schema.
Queries
# Get all posts
query {
posts {
id
title
status
author {
name
}
}
}
# Get single post
query {
post(where: { id: "123" }) {
title
content {
document
}
}
}
# Filter and sort
query {
posts(
where: {
status: { equals: "published" }
title: { contains: "javascript" }
}
orderBy: { publishedAt: desc }
take: 10
skip: 0
) {
id
title
publishedAt
}
}
# Count
query {
postsCount(where: { status: { equals: "published" } })
}
Mutations
# Create
mutation {
createPost(data: {
title: "New Post"
content: { document: [...] }
author: { connect: { id: "user-id" } }
}) {
id
title
}
}
# Update
mutation {
updatePost(
where: { id: "123" }
data: { title: "Updated Title" }
) {
id
title
}
}
# Delete
mutation {
deletePost(where: { id: "123" }) {
id
}
}
# Create many
mutation {
createPosts(data: [
{ title: "Post 1" },
{ title: "Post 2" }
]) {
id
title
}
}
Filter Operators
where: {
# Equality
title: { equals: "Hello" }
title: { not: { equals: "Hello" } }
# String matching
title: { contains: "react" }
title: { startsWith: "How to" }
title: { endsWith: "Guide" }
# Comparison
views: { gt: 100 }
views: { gte: 100 }
views: { lt: 1000 }
views: { lte: 1000 }
# List
status: { in: ["draft", "review"] }
status: { notIn: ["archived"] }
# Relationship
author: { id: { equals: "user-id" } }
author: { name: { contains: "John" } }
tags: { some: { name: { equals: "react" } } }
tags: { every: { name: { in: ["react", "javascript"] } } }
tags: { none: { name: { equals: "deprecated" } } }
# Logical
AND: [{ status: { equals: "published" } }, { featured: { equals: true } }]
OR: [{ status: { equals: "published" } }, { author: { id: { equals: "me" } } }]
NOT: { status: { equals: "archived" } }
}
Query API (Server-Side)
// In resolvers, hooks, or custom API routes
const posts = await context.query.Post.findMany({
where: { status: { equals: 'published' } },
orderBy: { publishedAt: 'desc' },
take: 10,
query: 'id title slug author { name }',
});
const post = await context.query.Post.findOne({
where: { id: 'post-id' },
query: 'id title content { document } author { name email }',
});
// Create
const newPost = await context.query.Post.createOne({
data: {
title: 'New Post',
author: { connect: { id: context.session?.itemId } },
},
query: 'id title',
});
// Update
await context.query.Post.updateOne({
where: { id: 'post-id' },
data: { title: 'Updated' },
});
// Delete
await context.query.Post.deleteOne({
where: { id: 'post-id' },
});
Authentication
// auth.ts
import { createAuth } from '@keystone-6/auth';
import { statelessSessions } from '@keystone-6/core/session';
const { withAuth } = createAuth({
listKey: 'User',
identityField: 'email',
secretField: 'password',
sessionData: 'id name email isAdmin',
initFirstItem: {
fields: ['name', 'email', 'password'],
},
});
const session = statelessSessions({
maxAge: 60 * 60 * 24 * 30, // 30 days
secret: process.env.SESSION_SECRET!,
});
export { withAuth, session };
File Storage
// keystone.ts
import { config } from '@keystone-6/core';
export default config({
storage: {
local: {
kind: 'local',
type: 'file',
generateUrl: (path) => `/files${path}`,
serverRoute: { path: '/files' },
storagePath: 'public/files',
},
s3: {
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_KEY!,
},
},
// ...
});
Custom GraphQL
import { graphql } from '@keystone-6/core';
export const lists = {
Post: list({
fields: {/* ... */},
}),
};
export const extendGraphqlSchema = graphql.extend((base) => ({
query: {
featuredPosts: graphql.field({
type: graphql.list(graphql.nonNull(base.object('Post'))),
resolve: async (root, args, context) => {
return context.query.Post.findMany({
where: { featured: { equals: true } },
orderBy: { publishedAt: 'desc' },
take: 5,
});
},
}),
},
mutation: {
publishPost: graphql.field({
type: base.object('Post'),
args: { id: graphql.arg({ type: graphql.nonNull(graphql.ID) }) },
resolve: async (root, { id }, context) => {
return context.query.Post.updateOne({
where: { id },
data: {
status: 'published',
publishedAt: new Date().toISOString(),
},
});
},
}),
},
}));
Next.js Integration
// lib/keystone.ts
const KEYSTONE_URL = process.env.KEYSTONE_URL || 'http://localhost:3000';
export async function fetchGraphQL(query: string, variables = {}) {
const response = await fetch(`${KEYSTONE_URL}/api/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const { data, errors } = await response.json();
if (errors) {
throw new Error(errors[0].message);
}
return data;
}
// Typed fetchers
export async function getPosts() {
const { posts } = await fetchGraphQL(`
query {
posts(where: { status: { equals: "published" } }, orderBy: { publishedAt: desc }) {
id
title
slug
excerpt
publishedAt
author { name }
}
}
`);
return posts;
}
export async function getPost(slug: string) {
const { posts } = await fetchGraphQL(`
query GetPost($slug: String!) {
posts(where: { slug: { equals: $slug } }) {
id
title
content { document }
author { name }
}
}
`, { slug });
return posts[0];
}
Deployment
# Build
npm run build
# Start production
npm run start
Database migrations are handled automatically. For production:
- Use PostgreSQL or MySQL (not SQLite)
- Set
SESSION_SECRETenvironment variable - Configure storage for uploads (S3 recommended)
Best Practices
- Use TypeScript for full type safety
- Define access control for all lists
- Use hooks for business logic and side effects
- Extend GraphQL for custom operations
- Set up proper relationships with
refbidirectional references - Use the Query API for server-side data fetching