Skills 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.

install
source · Clone the upstream repo
git clone https://github.com/TerminalSkills/skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/TerminalSkills/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/keystonejs" ~/.claude/skills/terminalskills-skills-keystonejs && rm -rf "$T"
manifest: skills/keystonejs/SKILL.md
safety · automated scan (low risk)
This is a pattern-based risk scan, not a security review. Our crawler flagged:
  • references .env files
Always read a skill's source content before installing. Patterns alone don't mean the skill is malicious — but they warrant attention.
source content

KeystoneJS — Headless CMS & Application Framework

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

  1. Schema is code — Define your content model in TypeScript; get auto-generated Admin UI, GraphQL API, and database migrations
  2. Use relationships over IDs — Let Keystone manage foreign keys; bidirectional relationships (
    ref: "Post.author"
    ) keep data consistent
  3. Access control from the start — Don't use
    allowAll
    in production; define operation + filter + item level access per list
  4. Document field for rich content — The document field gives editors a structured rich text experience with custom blocks
  5. Hooks for business logic — Use
    afterOperation
    for side effects (notifications, cache invalidation, webhooks)
  6. Custom GraphQL for complex queries — Extend the schema for aggregations, full-text search, or multi-step operations
  7. Prisma migrations — Keystone uses Prisma under the hood; run
    keystone prisma migrate
    for production migrations
  8. Separate API from admin — In production, protect the admin UI behind authentication; expose only the GraphQL API publicly