Claude Code for KeystoneJS: Node.js CMS and App Framework — Claude Skills 360 Blog
Blog / Backend / Claude Code for KeystoneJS: Node.js CMS and App Framework
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Published: June 7, 2027
Read time: 6 min read
By: Claude Skills 360

KeystoneJS is a Node.js CMS and app framework that auto-generates a GraphQL API — config({ db, lists, session, ui }) defines everything. list("Post", { fields: { title: text(), body: document(), author: relationship({ ref: "User" }) } }) creates a list with a generated CRUD API. access: { operation: { query: isAuthenticated, create: isAdmin } } controls list-level permissions. hooks: { beforeOperation: async ({ operation, resolvedData }) => { ... } } adds lifecycle hooks. Session: statelessSessions({ secret, maxAge }) signs cookies. session.get(context) retrieves the current user. context.db.User.findMany() runs raw DB queries outside GraphQL. The Admin UI at /keystonejs provides a CMS interface. File/image fields: image({ storage: "localImages" }) stores uploads. Custom routes: extendExpressApp: (app, context) => { app.get("/api/custom", handler) }. Claude Code generates Keystone schemas, access rules, hooks, and custom API endpoints.

CLAUDE.md for KeystoneJS

## KeystoneJS Stack
- Version: @keystone-6/core >= 6.5
- Config: keystone.ts at project root exporting config() object
- List: list("Post", { fields: { title: text({ validation: { isRequired: true } }), status: select({ options: ["draft","published"] }) } })
- Relationship: relationship({ ref: "User", many: false }) — one-to-many with many: true
- Access: access: { operation: { query: () => true, create: isAdmin, update: isOwner, delete: isAdmin } }
- Session: statelessSessions({ secret: SESSION_SECRET, maxAge: 60*60*24*30 })
- Context: context.session?.data — user from session; context.db.Post.findMany() for direct DB
- Custom endpoint: extendExpressApp: (app, ctx) => { app.get("/api/rss", rssHandler) }

Keystone Config

// keystone.ts — KeystoneJS configuration
import { config } from "@keystone-6/core"
import { statelessSessions } from "@keystone-6/core/session"
import { createAuth } from "@keystone-6/auth"
import { lists } from "./schema"
import type { Session } from "./types"

const { withAuth } = createAuth({
  listKey: "User",
  identityField: "email",
  secretField: "password",
  sessionData: "id name email role",
  initFirstItem: {
    fields: ["name", "email", "password"],
    itemData: { role: "admin" },
  },
})

export default withAuth(
  config({
    db: {
      provider: "postgresql",
      url: process.env.DATABASE_URL!,
      enableLogging: ["query"],
      useMigrations: true,
    },

    lists,

    session: statelessSessions<Session>({
      maxAge: 60 * 60 * 24 * 30,  // 30 days
      secret: process.env.SESSION_SECRET!,
    }),

    ui: {
      isAccessAllowed: (context) => !!context.session?.data,
    },

    server: {
      cors: {
        origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
        credentials: true,
      },
      extendExpressApp: (app, commonContext) => {
        // Custom REST endpoints alongside GraphQL
        app.get("/api/health", (_req, res) => {
          res.json({ ok: true, timestamp: new Date().toISOString() })
        })

        app.get("/api/posts/feed", async (_req, res) => {
          const context = await commonContext.withRequest(_req, res)
          const posts = await context.db.Post.findMany({
            where: { status: { equals: "published" } },
            orderBy: [{ publishedAt: "desc" }],
            take: 20,
          })
          res.json(posts)
        })
      },
    },

    graphql: {
      playground: process.env.NODE_ENV === "development",
      apolloConfig: {
        introspection: true,
      },
    },
  })
)

Schema Definitions

// schema/index.ts — export all lists
export { User } from "./User"
export { Post } from "./Post"
export { Tag } from "./Tag"
export { Comment } from "./Comment"
// schema/User.ts — User list definition
import {
  list,
  graphql,
} from "@keystone-6/core"
import {
  text,
  password,
  select,
  relationship,
  timestamp,
  checkbox,
} from "@keystone-6/core/fields"
import type { Lists } from ".keystone/types"

export const User: Lists.User = list({
  access: {
    operation: {
      query: () => true,
      create: ({ session }) => !!session?.data?.role && session.data.role === "admin",
      update: ({ session, item }) =>
        session?.data?.role === "admin" || session?.data?.id === item.id.toString(),
      delete: ({ session }) => session?.data?.role === "admin",
    },
  },

  fields: {
    name: text({ validation: { isRequired: true } }),
    email: text({
      validation: { isRequired: true },
      isIndexed: "unique",
    }),
    password: password({ validation: { isRequired: true } }),
    role: select({
      options: [
        { label: "User", value: "user" },
        { label: "Admin", value: "admin" },
        { label: "Editor", value: "editor" },
      ],
      defaultValue: "user",
      ui: { displayMode: "select" },
    }),
    isActive: checkbox({ defaultValue: true }),
    posts: relationship({ ref: "Post.author", many: true }),
    createdAt: timestamp({ defaultValue: { kind: "now" } }),
  },

  ui: {
    listView: { initialColumns: ["name", "email", "role", "isActive"] },
  },
})
// schema/Post.ts — Post list with hooks and access
import { list } from "@keystone-6/core"
import {
  text,
  select,
  relationship,
  timestamp,
  document,
  checkbox,
  integer,
} from "@keystone-6/core/fields"
import type { Lists } from ".keystone/types"

export const Post: Lists.Post = list({
  access: {
    operation: {
      query: ({ session }) => {
        // Published posts are public, drafts require authentication
        return true  // Field-level filtering in filter hook
      },
      create: ({ session }) => !!session?.data,
      update: ({ session, item }) =>
        session?.data?.role === "admin" ||
        session?.data?.id === (item as any).authorId?.toString(),
      delete: ({ session }) =>
        session?.data?.role === "admin" || session?.data?.role === "editor",
    },
    filter: {
      query: ({ session }) => {
        // Non-admins can only see published posts
        if (session?.data?.role === "admin" || session?.data?.role === "editor") {
          return {}  // No filter — see all
        }
        return { status: { equals: "published" } }
      },
    },
  },

  hooks: {
    beforeOperation: {
      create: async ({ resolvedData, context }) => {
        // Auto-generate slug from title
        if (resolvedData.title && !resolvedData.slug) {
          resolvedData.slug = resolvedData.title
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, "-")
            .replace(/^-|-$/g, "")
        }

        // Set author to current user if not provided
        if (!resolvedData.author && context.session?.data?.id) {
          resolvedData.author = { connect: { id: context.session.data.id } }
        }
      },
      update: async ({ resolvedData }) => {
        // Set publishedAt when status changes to published
        if (resolvedData.status === "published" && !resolvedData.publishedAt) {
          resolvedData.publishedAt = new Date()
        }
      },
    },
    afterOperation: {
      create: async ({ item, context }) => {
        console.log(`[Keystone] Post created: ${item.title} (${item.id})`)
        // Could trigger notifications, webhooks, etc.
      },
    },
  },

  fields: {
    title: text({
      validation: { isRequired: true },
      ui: { displayMode: "input" },
    }),
    slug: text({ isIndexed: "unique" }),
    excerpt: text({ ui: { displayMode: "textarea" } }),
    body: document({
      formatting: true,
      links: true,
      dividers: true,
      layouts: [[1, 1], [1, 1, 1]],
    }),
    status: select({
      options: [
        { label: "Draft", value: "draft" },
        { label: "Published", value: "published" },
        { label: "Archived", value: "archived" },
      ],
      defaultValue: "draft",
      ui: { displayMode: "segmented-control" },
    }),
    featured: checkbox({ defaultValue: false }),
    author: relationship({ ref: "User.posts" }),
    tags: relationship({ ref: "Tag.posts", many: true }),
    viewCount: integer({ defaultValue: 0 }),
    publishedAt: timestamp(),
    createdAt: timestamp({ defaultValue: { kind: "now" } }),
    updatedAt: timestamp({ db: { updatedAt: true } }),
  },

  ui: {
    listView: {
      initialColumns: ["title", "status", "author", "publishedAt", "viewCount"],
    },
  },
})

Using Keystone’s Context API

// lib/keystone/queries.ts — server-side Keystone DB queries
import { getContext } from "@keystone-6/core/context"
import config from "../../keystone"
import * as PrismaModule from "@prisma/client"

const keystoneContext = getContext(config, PrismaModule)

export async function getPublishedPosts(limit = 10) {
  return keystoneContext.db.Post.findMany({
    where: { status: { equals: "published" } },
    orderBy: [{ publishedAt: "desc" }],
    take: limit,
  })
}

export async function getPostBySlug(slug: string) {
  return keystoneContext.db.Post.findOne({
    where: { slug },
  })
}

export async function incrementViewCount(postId: string) {
  await keystoneContext.db.Post.updateOne({
    where: { id: postId },
    data: { viewCount: { increment: 1 } },
  })
}

For the Payload CMS alternative when a more modern TypeScript-native CMS with a block-based rich text editor, local API (no HTTP overhead), and better Next.js App Router integration is needed — Payload 3.0 runs inside Next.js as a plugin while Keystone is a standalone process, see the Payload CMS guide. For the Strapi alternative when a widely-adopted open-source CMS with a large plugin marketplace, REST + GraphQL APIs out of the box, and a visual content type builder with no code required is preferred — Strapi is less opinionated than Keystone and has better non-developer tooling, see the Strapi guide. The Claude Skills 360 bundle includes KeystoneJS skill sets covering schema, access control, and hooks. Start with the free tier to try Node.js CMS generation.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for Hono Advanced: Edge-First Web Framework Patterns

Advanced Hono patterns with Claude Code — Hono RPC with hc typed client, factory createFactory for reusable middleware, validator middleware with Zod, streaming responses with streamText and streamSSE, WebSocket upgrader with upgradeWebSocket, Cloudflare Workers bindings with c.env, D1 database access, KV namespace, R2 bucket, middleware composition with compose, testing with app.request, and monorepo sharing of Hono RPC types.

6 min read Jun 6, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free