QUESTPIE

Custom Fields

Create custom field types with custom storage, validation, and admin rendering.

Create field types that don't exist in the core -- color pickers, phone numbers, currencies, coordinates, or domain-specific types.

Overview

A custom field requires two parts:

  1. Server field factory -- defines storage, validation, and metadata
  2. Admin renderer -- React component for editing and displaying the field

Both are registered through a module and discovered by codegen.


Server: Field Factory

Define a field factory that specifies how the value is stored and validated.

src/fields/color.ts
import { varchar } from "drizzle-orm/pg-core";
import { z } from "zod";

/**
 * Color field — stores a hex color string.
 */
export function colorField(columnName: string) {
	return {
		toColumn: (name: string) => varchar(columnName || name, { length: 7 }),
		$types: {
			input: "" as string,
			output: "" as string,
			column: varchar("", { length: 7 }),
		},
		getLocation: () => "main" as const,
		getMetadata: () => ({
			type: "color" as const,
			validation: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color"),
		}),
	};
}

Field Factory Contract

A field factory must return an object with:

PropertyTypeDescription
toColumn()(name) => PgColumn | nullCreate a Drizzle column (or null for virtual)
$types{ input, output, column }Type inference helpers
getLocation()() => "main" | "i18n" | "virtual"Where the field is stored
getMetadata()() => { type, ... }Metadata for admin introspection

Admin: Field Renderer

Register a React component to render the field in the admin panel.

src/admin/fields/color-field.tsx
import type { FieldComponentProps } from "@questpie/admin/client";

export function ColorField({
	value,
	onChange,
	field,
}: FieldComponentProps<string>) {
	return (
		<div>
			<label>{field.label}</label>
			<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
				<input
					type="color"
					value={value || "#000000"}
					onChange={(e) => onChange?.(e.target.value)}
				/>
				<input
					type="text"
					value={value || ""}
					onChange={(e) => onChange?.(e.target.value)}
					placeholder="#000000"
					pattern="^#[0-9a-fA-F]{6}$"
				/>
			</div>
		</div>
	);
}

Register via File Convention

Create a field definition file using the field() factory:

src/admin/fields/color.ts
import { field } from "@questpie/admin/client";

export default field("color", {
	component: () => import("./color-field.js"),
	cell: () => import("./color-cell.js"),
});

The optional cell component renders the field in list view table columns:

src/admin/fields/color-cell.tsx
export function ColorCell({ value }: { value: string }) {
	return (
		<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
			<div
				style={{
					width: 16,
					height: 16,
					borderRadius: 4,
					backgroundColor: value || "#ccc",
				}}
			/>
			<span>{value || "—"}</span>
		</div>
	);
}

Module Registration

Package the server-side field factory into a module so it extends the f proxy:

src/modules/custom-fields.ts
import { module } from "questpie/app";
import { colorField } from "../fields/color";
import { currencyField } from "../fields/currency";

export const customFieldsModule = module({
	name: "custom-fields",
	fields: {
		color: colorField,
		currency: currencyField,
	},
});

Register the module in modules.ts:

src/questpie/server/modules.ts
import { adminModule } from "@questpie/admin/modules/admin";
import { customFieldsModule } from "../../modules/custom-fields";

export default [adminModule, customFieldsModule] as const;

After running questpie generate, the fields become available on the f builder:

collection("products").fields(({ f }) => ({
	name: f.text().required(),
	brandColor: f.color(), // Your custom field!
	price: f.currency("USD"), // Your custom field!
}));

Localized Custom Fields

To support localization, return "i18n" from getLocation():

getLocation: () => "i18n" as const,

This stores the field value in the i18n table, one per locale.


On this page