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

fetch
$curl "https://skillshub.wtf/TerminalSkills/skills/keystonejs?format=md"
SKILL.mdkeystonejs

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

> related_skills --same-repo

> zustand

You are an expert in Zustand, the small, fast, and scalable state management library for React. You help developers manage global state without boilerplate using Zustand's hook-based stores, selectors for performance, middleware (persist, devtools, immer), computed values, and async actions — replacing Redux complexity with a simple, un-opinionated API in under 1KB.

> zoho

Integrate and automate Zoho products. Use when a user asks to work with Zoho CRM, Zoho Books, Zoho Desk, Zoho Projects, Zoho Mail, or Zoho Creator, build custom integrations via Zoho APIs, automate workflows with Deluge scripting, sync data between Zoho apps and external systems, manage leads and deals, automate invoicing, build custom Zoho Creator apps, set up webhooks, or manage Zoho organization settings. Covers Zoho CRM, Books, Desk, Projects, Creator, and cross-product integrations.

> zod

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

> zipkin

Deploy and configure Zipkin for distributed tracing and request flow visualization. Use when a user needs to set up trace collection, instrument Java/Spring or other services with Zipkin, analyze service dependencies, or configure storage backends for trace data.

┌ stats

installs/wk0
░░░░░░░░░░
github stars17
███░░░░░░░
first seenMar 17, 2026
└────────────

┌ repo

TerminalSkills/skills
by TerminalSkills
└────────────

┌ tags

└────────────