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.







