Adding a Field Type
Every place a new field type touches, from its config helper to its MySQL column and admin input, using a URL field as the example.
This recipe adds a hypothetical urlField: a single-line input that only accepts http(s) URLs. Use it as a checklist for any new type.
Overview
| # | Where | What |
|---|---|---|
| 1 | core/types/index.ts | Add the type literal to FieldType |
| 2 | core/fields/url.ts | Options schema, UrlField class, urlField() helper |
| 3 | core/fields/index.ts | Export the class, helper, types; add to the FieldConfig union |
| 4 | validators/url.ts | Client-side Zod schema; export from validators/index.ts |
| 5 | apps/cms/components/form/inputs/url-form-input.tsx | The admin input |
| 6 | apps/cms/components/form/form-inputs.tsx, form.tsx | Render it for type === 'url'; add its validator to the form schema |
| 7 | cli/lib/update-sections.ts | Column type in generateFieldSQL() and type comparison |
| 8 | cli/utils/schema-generator.ts | Drizzle column |
| 9 | translations/base/en.ts (+ languages) | Error message keys |
| 10 | Tests | Field, validator, schema sync, component, e2e |
| 11 | apps/docs/content/docs/fields/url.mdx + nav | Documentation |
| 12 | .changeset/*.md | minor changeset |
1. Register the type
export type FieldType =
| 'text'
// ...
| 'url'2. The field module
import * as z from 'zod'
import type { BaseFieldConfig, FieldClientConfig } from './field.js'
import getString from '../../translations/index.js'
import { entityKind } from '../helpers/index.js'
import { baseFieldConfigSchema, Field } from './field.js'
const configSchema = z.strictObject({
placeholder: z.string().optional().describe('The placeholder text for the field'),
/**
* Protocols accepted by the field.
* @default ['https', 'http']
*/
protocols: z.array(z.enum(['http', 'https'])).min(1).optional().describe('Accepted protocols'),
defaultValue: z.string().optional().describe('The default value of the field'),
})
type Config = z.infer<typeof configSchema>
export class UrlField extends Field<'url', Config> {
static override readonly [entityKind]: string = 'UrlField'
readonly placeholder: string | undefined
readonly protocols: ('http' | 'https')[]
override value: string | undefined
constructor(config: BaseFieldConfig<Config>) {
super(config, 'url')
this.placeholder = config.placeholder
this.protocols = config.protocols ?? ['https', 'http']
this.value = config.defaultValue
}
getValue(): string | undefined {
return this.value
}
checkRequired() {
if (this.required && (!this.value || this.value.trim() === '')) {
throw new Error(getString('fieldIsRequired', this.language, { field: this.getLocalizedLabel() }))
}
}
async prepareForSubmission() {
if (!this.value) return
let url: URL
try {
url = new URL(this.value.trim())
} catch {
throw new Error(getString('invalidUrl', this.language, { field: this.getLocalizedLabel() }))
}
if (!this.protocols.includes(url.protocol.replace(':', '') as 'http' | 'https')) {
throw new Error(getString('invalidUrl', this.language, { field: this.getLocalizedLabel() }))
}
this.value = url.toString()
}
public override exportForClient(): FieldClientConfig & {
type: 'url'
placeholder?: string
protocols: ('http' | 'https')[]
} {
return { ...super.exportForClient(), placeholder: this.placeholder, protocols: this.protocols }
}
}
export type UrlFieldClientConfig = ReturnType<UrlField['exportForClient']>
const optionsSchema = z.strictObject({ ...baseFieldConfigSchema.shape, ...configSchema.shape })
const urlFieldConfigSchema = z.strictObject({
...optionsSchema.shape,
type: z.literal('url'),
build: z.function().output(z.instanceof(UrlField)),
})
export type UrlFieldConfig = z.infer<typeof urlFieldConfigSchema>
export function urlField(field: z.infer<typeof optionsSchema>): UrlFieldConfig {
const result = optionsSchema.safeParse(field)
if (!result.success) {
throw new Error(`[Field: ${field.name}]: ${z.prettifyError(result.error)}`)
}
return { ...field, type: 'url' as const, build: () => new UrlField(field) } satisfies UrlFieldConfig
}Conventions to keep:
z.strictObjectso typos in user configs fail at load time with the field name in the message.- Defaults applied in the constructor, not in the schema, so the config object stays exactly what the user wrote.
- Translated errors via
getStringwith the localized label. static [entityKind]sois(field, UrlField)works across module instances.- Serializable
exportForClient(): no functions, class instances, orDateobjects the client can't handle.
3. Export it
export { UrlField, urlField } from './url.js'
export type { UrlFieldClientConfig, UrlFieldConfig } from './url.js'
// ...
import type { UrlFieldConfig } from './url.js'
export type FieldConfig =
| TextFieldConfig
// ...
| UrlFieldConfig4. Client validator
import * as z from 'zod'
import type { UrlFieldClientConfig } from '../core/fields/index.js'
import getString from '../translations/index.js'
export const urlFieldSchema = (field: UrlFieldClientConfig, language = 'en') => {
const requiredMessage = getString('requiredField', language)
const invalidMessage = getString('invalidUrl', language, { field: field.label })
const isAllowedUrl = (value: string) => {
try {
const protocol = new URL(value).protocol.replace(':', '') as 'http' | 'https'
return field.protocols.includes(protocol)
} catch {
return false
}
}
const base = z.string(requiredMessage).trim().min(1, requiredMessage).refine(isAllowedUrl, invalidMessage)
return field.required ? base : z.union([z.literal(''), base], requiredMessage).optional()
}Follow the shape of the existing validators (validators/text.ts): (field, language), messages from getString, and '' | value for optional fields. Import only types from core/fields: validators run in the browser. Then add a branch for 'url' where apps/cms/components/form/form.tsx builds the form schema from the inputs.
5–6. Admin input
Copy the closest existing input (text-form-input.tsx) to url-form-input.tsx, render <input type="url">, and add a case to the switch in form-inputs.tsx:
case 'url':
return <UrlFormInput input={input as UrlFieldClientConfig} />7–8. Column type
case is(input, UrlField):
fieldSQL += 'VARCHAR(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
breakPlace the case before any parent class it might match. Update the type-comparison helper in the same file (the function that maps a field to the expected column type when diffing), and add the Drizzle mapping in schema-generator.ts (varchar('col', { length: 2048 })).
9. Translations
invalidUrl: '{field} must be a valid URL',Add the key to packages/translations/src/languages/ar.ts as well.
10. Tests
core/fields/__tests__/url.test.ts: helper validation (unknown options throw),prepareForSubmissionaccepts/rejects, normalization.validators/url.test.ts.cli/lib/update-sections.test.ts: the generated column.- A component test (
*.test.tsx) for the input if it has logic. - Add the field to
apps/cms/sections/__TESTS__/e2e-all-fields.section.tsso the Playwright suite covers create/edit.
11. Docs
Create apps/docs/content/docs/fields/url.mdx following the structure of the other field pages (basic usage, every option, options table, options in detail, examples, storage), add it to lib/navigation.ts, and add a row to the table on docs/fields/index.mdx.
12. Changeset
pnpm changeset # nextjs-cms: minor — "Add urlField for validated http(s) links"