Custom Lists (Models) Development in KeystoneJS
Lists are the primary unit of data model in KeystoneJS. Each List corresponds to a database table, set of GraphQL operations, and section in Admin UI. Proper Lists architecture determines system flexibility.
List Anatomy
import { list } from '@keystone-6/core';
import { text, relationship, timestamp, integer, virtual } from '@keystone-6/core/fields';
export const Product = list({
// Access control at operation and field levels
access: { ... },
// Fields — core component
fields: { ... },
// Lifecycle hooks
hooks: { ... },
// Admin UI settings
ui: { ... },
// GraphQL extensions
graphql: { ... },
// Documentation
description: 'Product catalog',
});
Field Types and Usage
fields: {
// Basic
name: text({ validation: { isRequired: true }, isIndexed: true }),
sku: text({ isIndexed: 'unique' }),
price: integer({ validation: { min: 0 } }),
description: text({ ui: { displayMode: 'textarea' } }),
// Files and images
mainImage: image({ storage: 's3_images' }),
catalogPdf: file({ storage: 's3_files' }),
// Relationships
category: relationship({ ref: 'Category.products' }),
tags: relationship({ ref: 'Tag', many: true }),
variants: relationship({ ref: 'ProductVariant.product', many: true }),
// Timestamps
createdAt: timestamp({
defaultValue: { kind: 'now' },
ui: { createView: { fieldMode: 'hidden' } },
}),
updatedAt: timestamp({
db: { updatedAt: true },
ui: { createView: { fieldMode: 'hidden' } },
}),
// Virtual field (not stored in DB)
fullPriceWithTax: virtual({
field: graphql.field({
type: graphql.Float,
resolve(item) {
return (item.price || 0) * 1.2;
},
}),
}),
},
Lifecycle Hooks
hooks: {
// Before writing to DB — data modification
resolveInput: async ({ resolvedData, inputData, item, operation }) => {
if (operation === 'create' && !inputData.sku) {
resolvedData.sku = `PROD-${Date.now()}`;
}
return resolvedData;
},
// Validation before operation
validateInput: async ({ resolvedData, addValidationError }) => {
if (resolvedData.price !== undefined && resolvedData.price < 0) {
addValidationError('Price cannot be negative');
}
},
// After successful save — side effects
afterOperation: async ({ operation, item, originalItem, context }) => {
if (operation === 'update' && item.status === 'published' && originalItem?.status !== 'published') {
// Cache invalidation, webhook notification, etc.
await invalidateCache(`/products/${item.slug}`);
}
},
// Before deletion
beforeOperation: async ({ operation, item, context }) => {
if (operation === 'delete') {
// Check no related orders
const ordersCount = await context.db.OrderItem.count({
where: { product: { id: { equals: item.id } } },
});
if (ordersCount > 0) {
throw new Error('Cannot delete product with existing orders');
}
}
},
},
Two-way Relationships
KeystoneJS requires explicit both sides of relationship:
// Category.ts
export const Category = list({
fields: {
name: text({ validation: { isRequired: true } }),
products: relationship({ ref: 'Product.category', many: true }),
},
});
// Product.ts — reverse side
category: relationship({ ref: 'Category.products' }),
Prisma automatically creates necessary foreign keys.
Custom GraphQL Mutations
Sometimes business logic requires implementation beyond standard CRUD:
// keystone.ts — extendGraphqlSchema
extendGraphqlSchema: graphql.extend((base) => ({
mutation: {
publishProduct: graphql.field({
type: base.object('Product'),
args: { id: graphql.arg({ type: graphql.nonNull(graphql.ID) }) },
async resolve(source, { id }, context) {
if (!context.session?.data?.role === 'editor') {
throw new Error('Access denied');
}
return context.db.Product.updateOne({
where: { id },
data: { status: 'published', publishedAt: new Date() },
});
},
}),
},
})),
Admin UI Configuration
ui: {
label: 'Products',
singular: 'Product',
plural: 'Products',
listView: {
initialColumns: ['name', 'sku', 'price', 'status', 'category'],
initialSort: { field: 'createdAt', direction: 'DESC' },
pageSize: 50,
},
searchFields: ['name', 'sku'],
// Hide from Admin UI (API only)
isHidden: false,
},
Development Timeline
One well-developed List with relationships, hooks, and custom UI — 0.5–1 working day. Full data model for e-shop (10–15 Lists) — 5–8 days.







