Claude Code Plugins

Community-maintained marketplace

Feedback

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.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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:

  1. Use PostgreSQL or MySQL (not SQLite)
  2. Set SESSION_SECRET environment variable
  3. Configure storage for uploads (S3 recommended)

Best Practices

  1. Use TypeScript for full type safety
  2. Define access control for all lists
  3. Use hooks for business logic and side effects
  4. Extend GraphQL for custom operations
  5. Set up proper relationships with ref bidirectional references
  6. Use the Query API for server-side data fetching