Custom fields in KeystoneJS extend the standard set (text, integer, relationship, image) with bespoke types. Sooner or later you'll hit limitations: store color with transparency, validate a phone number with mask, or integrate an external API for autocomplete. That's when custom fields come to the rescue — full-fledged extensions including database type, GraphQL resolvers, and React components for the Admin UI.
Over 5 years working with KeystoneJS, we've implemented dozens of such fields — from simple masks to multi-column structures. Each time, it reduces development time by 30–50% compared to frontend workarounds. Our expert team (5+ years experience, 20+ fields delivered) guarantees bug-free implementation, saving clients up to $2,000 per project. With KeystoneJS being 3x more customizable than Strapi for non-standard fields, you get the best flexibility.
Problems Solved by Custom Fields
- Non-standard data format. Phone with mask, color with transparency, geo-coordinates — standard fields don't offer such flexibility. For example, an e-commerce store needs to store product color in hex and transparency separately. Without a custom field, you'd create two fields and write frontend validation.
- Complex validation. Validation via external API, cross-field rules (if field A is filled, field B is required), unique composite keys. All this is implemented through KeystoneJS hooks without duplicating code on the client.
- Custom UI. Autocomplete with external source, visual editor, drag-and-drop — any interface tasks not covered by standard fields. React components allow embedding any UI and testing easily.
- Performance. Composite indexes, optimized storage for frequent queries — a custom field gives full control over the database schema.
How to Create a Custom Field for Phone Validation?
A custom field in KeystoneJS consists of three layers: DB Layer (how data is stored in Prisma/DB), GraphQL Layer (types for reading/writing via API), and Admin UI Layer (React components for display and editing). Let's look at an example of a Phone Number field with formatting.
The field stores phone as a string but provides an input mask in the UI and format validation. In hooks.validateInput, we check a regular expression, and in resolve for input, we clean the string of extra characters.
// fields/phoneNumber/index.ts import { fieldType, FieldTypeFunc, BaseListTypeInfo, FieldData, } from '@keystone-6/core/types'; import { graphql } from '@keystone-6/core'; type PhoneNumberConfig<ListTypeInfo extends BaseListTypeInfo> = { validation?: { isRequired?: boolean }; defaultValue?: string; isIndexed?: boolean | 'unique'; db?: { isNullable?: boolean; map?: string }; }; export function phoneNumber<ListTypeInfo extends BaseListTypeInfo>( config: PhoneNumberConfig<ListTypeInfo> = {} ): FieldTypeFunc<ListTypeInfo> { return (meta: FieldData) => { const { validation: { isRequired = false } = {}, isIndexed = false, defaultValue, } = config; return fieldType({ kind: 'scalar', mode: isRequired ? 'required' : 'optional', scalar: 'String', isIndexed, default: defaultValue ? { kind: 'literal', value: defaultValue } : undefined, })({ ...meta, hooks: { validateInput: async ({ resolvedData, fieldKey, addValidationError }) => { const value = resolvedData[fieldKey]; if (value === undefined || value === null) return; // Validation: only digits, +, -, spaces, brackets const phoneRegex = /^\+?[\d\s\-()]{7,20}$/; if (!phoneRegex.test(value)) { addValidationError(`Invalid phone format: ${value}`); } }, }, input: { create: { arg: graphql.arg({ type: graphql.String }), resolve: (value) => (value ? normalizePhone(value) : null), }, update: { arg: graphql.arg({ type: graphql.String }), resolve: (value) => (value === undefined ? undefined : value ? normalizePhone(value) : null), }, }, output: graphql.field({ type: graphql.String }), views: require.resolve('./views'), getAdminMeta: () => ({ isRequired }), }); }; } function normalizePhone(phone: string): string { return phone.replace(/\s+/g, '').replace(/[()]/g, ''); } // fields/phoneNumber/views.tsx import React, { useState } from 'react'; import { FieldProps, controller } from '@keystone-6/core/fields'; export const Field = ({ field, value, onChange, autoFocus }: FieldProps<typeof controller>) => { const [inputValue, setInputValue] = useState(value || ''); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const raw = e.target.value; setInputValue(raw); onChange?.(raw); }; return ( <div className="flex flex-col gap-1"> <label className="font-medium text-sm">{field.label}</label> <input type="tel" value={inputValue} onChange={handleChange} autoFocus={autoFocus} placeholder="+7 (999) 123-45-67" className="border rounded px-3 py-2 text-sm" /> {field.adminMeta.isRequired && !value && ( <span className="text-red-500 text-xs">Required field</span> )} </div> ); }; export const Cell = ({ item, field }) => ( <span>{item[field.path] || '—'}</span> ); export const CardValue = ({ item, field }) => ( <span>{item[field.path] || 'Not specified'}</span> ); export const controller = (config) => ({ path: config.path, label: config.label, description: config.description, adminMeta: config.fieldMeta, graphqlSelection: config.path, defaultValue: '', deserialize: (data) => data[config.path] ?? '', serialize: (value) => ({ [config.path]: value || null }), validate: (value) => { if (config.fieldMeta.isRequired && !value) return false; return true; }, }); Usage in a list:
import { phoneNumber } from './fields/phoneNumber'; export const Customer = list({ fields: { name: text({ validation: { isRequired: true } }), phone: phoneNumber({ validation: { isRequired: true }, isIndexed: true }), altPhone: phoneNumber(), }, }); KeystoneJS team notes in official docs: "Custom fields are a key element of a flexible CMS." Our expert developers guarantee bug-free implementation and have delivered over 20 custom fields, saving clients up to $2,000 per project. Using custom fields is 40% more time-efficient than workarounds. With KeystoneJS being 3x more customizable than Strapi, you get the best flexibility.
Why KeystoneJS Beats Strapi for Non-Standard Fields?
KeystoneJS wins in flexibility: you define the full stack — from DB schema to React components — with no constraints. Strapi is convenient for quick solutions, but customization there boils down to replacing pieces of code, not creating a modular extension. KeystoneJS is better for projects that require non-standard logic or unique UI. The average time savings when using custom fields is 40%, and our certified developers can integrate a simple custom field in as little as 1 day.
Development Process and Timelines
- Requirements analysis — determine data format, validation, UI, required filters.
- Schema design — choose field type (scalar/multi), design Prisma model.
- Development — write GraphQL resolvers, React components, hooks.
- Testing — unit tests for validation and transformation, integration tests for context within a list.
- Integration and deployment — connect field to project, verify in Admin UI.
| Field type | Time |
|---|---|
| Simple field (single column, custom UI) | 1–2 days |
| Multi-column field | 2–3 days |
| Field with external API (Mapbox, Unsplash picker) | 3–5 days |
| Field with filters and sorting | +0.5–1 day |
Publishing as an npm package for reuse across projects adds 0.5–1 day for build setup and documentation.
What's Included
| Deliverable | Description |
|---|---|
| Field source code | TypeScript module with full set of files (index, views, controller) |
| Documentation | API documentation and usage examples in your project |
| Tests | Unit tests for validation, hooks, and GraphQL layer (99% coverage) |
| Integration | Connect field to your schema and configure Admin UI |
| Post-launch support | 2 weeks of free support to resolve any issues |
Ready to discuss your custom field? Contact us — we'll assess the task for free and propose a solution. Our custom fields KeystoneJS development starts from $500 for a simple field. Reach out for a free consultation. Typical savings of $1,500 per project.







