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

First seen Jun 25, 2026

Installation

$ npx skills add terminalskills/skills --skill keystonejs

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from terminalskills/skills · top by installs.

npx skills add terminalskills/skills

Browse all from terminalskills/skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 146
License LICENSE
Default branch main
Open issues 1
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseApache-2.0
CompatibilityNo special requirements
More metadata
author
terminal-skills
version
1.0.0
category
content
tags
[]
0
cms
1
headless-cms
2
graphql
3
admin-ui
4
database

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 12,592 B
  • docs SUMMARY.md 297 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 3 installs

SKILL.md

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