nextjs-cms

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

#WhereWhat
1core/types/index.tsAdd the type literal to FieldType
2core/fields/url.tsOptions schema, UrlField class, urlField() helper
3core/fields/index.tsExport the class, helper, types; add to the FieldConfig union
4validators/url.tsClient-side Zod schema; export from validators/index.ts
5apps/cms/components/form/inputs/url-form-input.tsxThe admin input
6apps/cms/components/form/form-inputs.tsx, form.tsxRender it for type === 'url'; add its validator to the form schema
7cli/lib/update-sections.tsColumn type in generateFieldSQL() and type comparison
8cli/utils/schema-generator.tsDrizzle column
9translations/base/en.ts (+ languages)Error message keys
10TestsField, validator, schema sync, component, e2e
11apps/docs/content/docs/fields/url.mdx + navDocumentation
12.changeset/*.mdminor changeset

1. Register the type

packages/nextjs-cms/src/core/types/index.ts
export type FieldType =
    | 'text'
    // ...
    | 'url'

2. The field module

packages/nextjs-cms/src/core/fields/url.ts
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.strictObject so 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 getString with the localized label.
  • static [entityKind] so is(field, UrlField) works across module instances.
  • Serializable exportForClient(): no functions, class instances, or Date objects the client can't handle.

3. Export it

packages/nextjs-cms/src/core/fields/index.ts
export { UrlField, urlField } from './url.js'
export type { UrlFieldClientConfig, UrlFieldConfig } from './url.js'

// ...
import type { UrlFieldConfig } from './url.js'

export type FieldConfig =
    | TextFieldConfig
    // ...
    | UrlFieldConfig

4. Client validator

packages/nextjs-cms/src/validators/url.ts
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:

apps/cms/components/form/form-inputs.tsx
case 'url':
    return <UrlFormInput input={input as UrlFieldClientConfig} />

7–8. Column type

packages/nextjs-cms/src/cli/lib/update-sections.ts
case is(input, UrlField):
    fieldSQL += 'VARCHAR(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
    break

Place 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

packages/nextjs-cms/src/translations/base/en.ts
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), prepareForSubmission accepts/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.ts so 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

Terminal
pnpm changeset   # nextjs-cms: minor — "Add urlField for validated http(s) links"