Build Your Own Fields

You can create your own fields for your website project, either for personal use or to share it privately or publicly with others.

To get started, check some of the existing fields in Headcode CMS — like TextField, TextareaField, or ImageField — and use them as inspiration for your own custom fields.

Custom fields allow you to extend Headcode CMS with your own input types, validations, and admin UI components.

Structure

Each field consists of two parts:

  • Field definition: defines the structure, type, and validation (e.g., text-field.ts)

  • Field component: defines how the field is rendered and edited inside the Headcode Admin (e.g., text-field-component.tsx)

Examples

text-field.ts
import { lazy } from 'react'import { z } from 'zod'import type { FieldProps } from '@/lib/headcode/types'const DefaultTextField: FieldProps<string, unknown> = {  label: 'Text Field',  component: lazy(() => import('./text-field-component')),  defaultValue: '',  validator: z.string(),}export const TextField = (params: Partial<FieldProps<string, unknown>>) => ({  ...DefaultTextField,  ...params,})
text-field-component.tsx
import {  Field,  FieldDescription,  FieldError,  FieldLabel,} from '@/components/ui/field'import { Input } from '@/components/ui/input'import { useFieldContext } from './app-form'export default function TextFieldComponent({  label,  description,}: {  label: string  description?: string | undefined}) {  const field = useFieldContext<string>()  const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid  return (    <Field data-invalid={isInvalid}>      <FieldLabel htmlFor={field.name}>{label}</FieldLabel>      <Input        id={field.name}        name={field.name}        value={field.state.value}        onBlur={field.handleBlur}        onChange={(e) => field.handleChange(e.target.value)}        aria-invalid={isInvalid}        autoComplete="off"      />      {description && <FieldDescription>{description}</FieldDescription>}      {isInvalid && <FieldError errors={field.state.meta.errors} />}    </Field>  )}

Note, that with const field = useFieldContext<string>() you get the TanStack field context.

The Link Field combines multiple fields into a new field type:

link-field.ts
import { lazy, type ComponentType } from 'react'import { z } from 'zod'import type { FieldProps } from '@/lib/headcode/types'export type LinkValue = {  title: string  url: string  openInNewWindow: boolean}const DefaultLinkField: FieldProps<LinkValue, unknown> = {  label: 'Link Field',  component: lazy(() => import('./link-field-component')) as ComponentType<{    label: string    description?: string    options?: unknown  }>,  defaultValue: {    title: '',    url: '',    openInNewWindow: false,  },  validator: z.object({    title: z.string(),    url: z.string(),    openInNewWindow: z.boolean().default(false),  }),}export const LinkField = (params: Partial<FieldProps<LinkValue, unknown>>) => ({  ...DefaultLinkField,  ...params,})
link-field-component.tsx
import {  Field,  FieldDescription,  FieldError,  FieldLabel,} from '@/components/ui/field'import { Input } from '@/components/ui/input'import { Checkbox } from '@/components/ui/checkbox'import { useFieldContext } from './app-form'import type { LinkValue } from './link-field'export default function LinkFieldComponent({  label,  description,}: {  label: string  description?: string | undefined}) {  const field = useFieldContext<LinkValue>()  const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid  const value = field.state.value || {    title: '',    url: '',    openInNewWindow: false,  }  const handleTitleChange = (title: string) => {    field.handleChange({      ...value,      title,    })  }  const handleUrlChange = (url: string) => {    field.handleChange({      ...value,      url,    })  }  const handleOpenInNewWindowChange = (openInNewWindow: boolean) => {    field.handleChange({      ...value,      openInNewWindow,    })  }  return (    <Field data-invalid={isInvalid}>      {isInvalid && <FieldError errors={field.state.meta.errors} />}      <div className="space-y-4">        <div className="flex flex-col gap-4 md:flex-row md:gap-4">          <div className="flex-1">            <FieldLabel htmlFor={`${field.name}-title`} className="text-sm">              {label}            </FieldLabel>            <Input              id={`${field.name}-title`}              name={`${field.name}.title`}              value={value.title}              onBlur={field.handleBlur}              onChange={(e) => handleTitleChange(e.target.value)}              aria-invalid={isInvalid}              autoComplete="off"              className="mt-1"            />          </div>          <div className="flex-1">            <FieldLabel htmlFor={`${field.name}-url`} className="text-sm">              URL            </FieldLabel>            <Input              id={`${field.name}-url`}              name={`${field.name}.url`}              value={value.url}              onBlur={field.handleBlur}              onChange={(e) => handleUrlChange(e.target.value)}              aria-invalid={isInvalid}              autoComplete="off"              className="mt-1"            />          </div>        </div>        <div className="flex items-center gap-2">          <Checkbox            id={`${field.name}-openInNewWindow`}            name={`${field.name}.openInNewWindow`}            checked={value.openInNewWindow}            onCheckedChange={(checked) =>              handleOpenInNewWindowChange(checked === true)            }            onBlur={field.handleBlur}            aria-invalid={isInvalid}          />          <FieldLabel            htmlFor={`${field.name}-openInNewWindow`}            className="cursor-pointer text-sm font-normal"          >            Open in new window          </FieldLabel>        </div>      </div>      {description && <FieldDescription>{description}</FieldDescription>}    </Field>  )}