keystonejs
Expert guidance for KeystoneJS, the open-source headless CMS and application platform built on Node.js, GraphQL, and Prisma. Helps developers define content schemas, build admin interfaces, implement access control, and create custom GraphQL APIs for content-driven applications.
Usage
Getting Started
- Install the skill using the command above
- Open your AI coding agent (Claude Code, Codex, Gemini CLI, or Cursor)
- Reference the skill in your prompt
- The AI will use the skill's capabilities automatically
Example Prompts
- "Write a blog post about the benefits of AI-assisted development"
- "Create social media copy for the product launch announcement"
Documentation
Overview
KeystoneJS, the open-source headless CMS and application platform built on Node.js, GraphQL, and Prisma. Helps developers define content schemas, build admin interfaces, implement access control, and create custom GraphQL APIs for content-driven applications.
Instructions
Schema Definition
Define your data model with Keystone's list API:
// keystone.ts — Main Keystone configuration
import { config, list } from "@keystone-6/core";
import { allowAll } from "@keystone-6/core/access";
import {
text, relationship, timestamp, select, integer,
image, json, checkbox, password, float,
} from "@keystone-6/core/fields";
import { document } from "@keystone-6/fields-document";
export default config({
db: {
provider: "postgresql", // "postgresql" | "sqlite" | "mysql"
url: process.env.DATABASE_URL!,
},
lists: {
// Blog post content type
Post: list({
access: allowAll, // Will customize below
fields: {
title: text({
validation: { isRequired: true },
isIndexed: "unique",
}),
slug: text({
validation: { isRequired: true },
isIndexed: "unique",
hooks: {
// Auto-generate slug from title if not provided
resolveInput({ resolvedData, item }) {
if (!resolvedData.slug && resolvedData.title) {
return resolvedData.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" },
}),
content: document({
// Rich text editor with custom blocks
formatting: true,
links: true,
dividers: true,
layouts: [
[1, 1], // Two-column layout
[1, 1, 1], // Three-column layout
],
componentBlocks: {}, // Custom editor components
}),
heroImage: image({
storage: "localImages", // Configured in storage section
}),
publishedAt: timestamp(),
author: relationship({
ref: "User.posts", // Bidirectional relationship
ui: { displayMode: "cards", cardFields: ["name", "email"] },
}),
categories: relationship({
ref: "Category.posts",
many: true, // Many-to-many relationship
ui: { displayMode: "select" },
}),
readingTime: integer({
ui: { createView: { fieldMode: "hidden" } }, // Auto-calculated
hooks: {
resolveInput({ resolvedData }) {
if (resolvedData.content) {
const wordCount = JSON.stringify(resolvedData.content).split(/\s+/).length;
return Math.ceil(wordCount / 200); // ~200 words per minute
}
return undefined;
},
},
}),
},
}),
// Category taxonomy
Category: list({
access: allowAll,
fields: {
name: text({ validation: { isRequired: true }, isIndexed: "unique" }),
description: text({ ui: { displayMode: "textarea" } }),
posts: relationship({ ref: "Post.categories", many: true }),
},
}),
// User with authentication
User: list({
access: allowAll,
fields: {
name: text({ validation: { isRequired: true } }),
email: text({ validation: { isRequired: true }, isIndexed: "unique" }),
password: password({
validation: { isRequired: true, length: { min: 8 } },
}),
role: select({
options: [
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
{ label: "Author", value: "author" },
],
defaultValue: "author",
}),
posts: relationship({ ref: "Post.author", many: true }),
},
}),
},
// File storage configuration
storage: {
localImages: {
kind: "local",
type: "image",
generateUrl: (path) => `/images${path}`,
serverRoute: { path: "/images" },
storagePath: "public/images",
},
},
// Authentication
session: {
maxAge: 60 * 60 * 24 * 30, // 30 days
secret: process.env.SESSION_SECRET!,
},
});
Access Control
Implement fine-grained permissions:
// access.ts — Role-based access control rules
import { ListAccessArgs } from "@keystone-6/core/types";
// Helper to check if user is authenticated
function isSignedIn({ session }: ListAccessArgs) {
return !!session;
}
// Helper to check user role
function hasRole(role: string) {
return ({ session }: ListAccessArgs) => session?.data?.role === role;
}
// Post-specific access rules
export const postAccess = {
operation: {
query: allowAll, // Anyone can read published posts
create: isSignedIn, // Must be logged in to create
update: isSignedIn,
delete: hasRole("admin"), // Only admins can delete
},
filter: {
// Non-admins can only see published posts (in query results)
query: ({ session }: ListAccessArgs) => {
if (session?.data?.role === "admin") return {}; // Admins see everything
return { status: { equals: "published" } };
},
// Authors can only update their own posts
update: ({ session }: ListAccessArgs) => {
if (session?.data?.role === "admin") return {};
return { author: { id: { equals: session?.itemId } } };
},
},
item: {
// Additional per-item checks
update: async ({ session, item }: any) => {
// Editors can update any post, authors only their own
if (session?.data?.role === "editor") return true;
return item.authorId === session?.itemId;
},
},
};
Custom GraphQL Extensions
Add custom queries and mutations:
// schema.extensions.ts — Extend the auto-generated GraphQL API
import { graphql } from "@keystone-6/core";
export const extendGraphqlSchema = graphql.extend((base) => ({
query: {
// Custom query: get featured posts
featuredPosts: graphql.field({
type: graphql.list(graphql.nonNull(base.object("Post"))),
args: { limit: graphql.arg({ type: graphql.Int, defaultValue: 5 }) },
async resolve(source, { limit }, context) {
return context.db.Post.findMany({
where: {
status: { equals: "published" },
},
orderBy: { publishedAt: "desc" },
take: limit,
});
},
}),
// Custom query: site statistics
siteStats: graphql.field({
type: graphql.object("SiteStats")({
fields: {
totalPosts: graphql.field({ type: graphql.Int }),
totalUsers: graphql.field({ type: graphql.Int }),
totalCategories: graphql.field({ type: graphql.Int }),
},
}),
async resolve(source, args, context) {
const [posts, users, categories] = await Promise.all([
context.db.Post.count({ where: { status: { equals: "published" } } }),
context.db.User.count(),
context.db.Category.count(),
]);
return {
totalPosts: posts,
totalUsers: users,
totalCategories: categories,
};
},
}),
},
mutation: {
// Custom mutation: publish a post
publishPost: graphql.field({
type: base.object("Post"),
args: { id: graphql.arg({ type: graphql.nonNull(graphql.ID) }) },
async resolve(source, { id }, context) {
return context.db.Post.updateOne({
where: { id },
data: {
status: "published",
publishedAt: new Date().toISOString(),
},
});
},
}),
},
}));
Webhooks and Hooks
React to data changes with lifecycle hooks:
// hooks/post-hooks.ts — Lifecycle hooks for post content
import { ListHooks } from "@keystone-6/core/types";
export const postHooks: ListHooks<any> = {
// Before saving to the database
resolveInput: {
create: async ({ resolvedData, context }) => {
// Auto-set the author to the current user
if (!resolvedData.author && context.session) {
resolvedData.author = { connect: { id: context.session.itemId } };
}
return resolvedData;
},
},
// After saving — trigger side effects
afterOperation: {
create: async ({ item, context }) => {
// Send notification when a new post is created
console.log(`New post created: ${item.title}`);
},
update: async ({ item, originalItem, context }) => {
// Trigger revalidation when a post is published
if (originalItem.status !== "published" && item.status === "published") {
await fetch(`${process.env.FRONTEND_URL}/api/revalidate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.REVALIDATION_SECRET,
path: `/posts/${item.slug}`,
}),
});
}
},
},
// Validate before saving
validateInput: async ({ resolvedData, addValidationError }) => {
if (resolvedData.status === "published" && !resolvedData.content) {
addValidationError("Cannot publish a post without content");
}
},
};
Installation
# Create a new Keystone project
npm create keystone-app@latest my-cms
# Or add to existing project
npm install @keystone-6/core @keystone-6/fields-document
# Run in development mode (auto-generates Prisma schema + Admin UI)
npx keystone dev
# Admin UI at http://localhost:3000
# GraphQL playground at http://localhost:3000/api/graphql
Examples
Example 1: Setting up Keystonejs with a custom configuration
User request:
I just installed Keystonejs. Help me configure it for my TypeScript + React workflow with my preferred keybindings.
The agent creates the configuration file with TypeScript-aware settings, configures relevant plugins/extensions for React development, sets up keyboard shortcuts matching the user's preferences, and verifies the setup works correctly.
Example 2: Extending Keystonejs with custom functionality
User request:
I want to add a custom access control to Keystonejs. How do I build one?
The agent scaffolds the extension/plugin project, implements the core functionality following Keystonejs's API patterns, adds configuration options, and provides testing instructions to verify it works end-to-end.
Guidelines
- Schema is code — Define your content model in TypeScript; get auto-generated Admin UI, GraphQL API, and database migrations
- Use relationships over IDs — Let Keystone manage foreign keys; bidirectional relationships (
ref: "Post.author") keep data consistent - Access control from the start — Don't use
allowAllin production; define operation + filter + item level access per list - Document field for rich content — The document field gives editors a structured rich text experience with custom blocks
- Hooks for business logic — Use
afterOperationfor side effects (notifications, cache invalidation, webhooks) - Custom GraphQL for complex queries — Extend the schema for aggregations, full-text search, or multi-step operations
- Prisma migrations — Keystone uses Prisma under the hood; run
keystone prisma migratefor production migrations - Separate API from admin — In production, protect the admin UI behind authentication; expose only the GraphQL API publicly
Information
- Version
- 1.0.0
- Author
- terminal-skills
- Category
- Content
- License
- Apache-2.0