diff --git a/.changeset/date-only-field.md b/.changeset/date-only-field.md
new file mode 100644
index 0000000000..9adb531812
--- /dev/null
+++ b/.changeset/date-only-field.md
@@ -0,0 +1,6 @@
+---
+"emdash": patch
+"@emdash-cms/admin": patch
+---
+
+Add a date-only content field type that stores `YYYY-MM-DD` values and renders as a date picker in the admin.
diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx
index 7095747fcb..0178e41272 100644
--- a/packages/admin/src/components/ContentEditor.tsx
+++ b/packages/admin/src/components/ContentEditor.tsx
@@ -2041,6 +2041,18 @@ function FieldRenderer({
/>
);
+ case "date":
+ return (
+ handleChange(e.target.value)}
+ required={field.required}
+ />
+ );
+
case "image": {
// value is either an ImageFieldValue object, a legacy string URL, or undefined
const imageValue =
diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx
index d2740bc2a3..32284b0391 100644
--- a/packages/admin/src/components/FieldEditor.tsx
+++ b/packages/admin/src/components/FieldEditor.tsx
@@ -213,6 +213,12 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
description: t`Date and time picker`,
icon: Calendar,
},
+ {
+ type: "date",
+ label: t`Date Only`,
+ description: t`Date picker without time`,
+ icon: Calendar,
+ },
{
type: "select",
label: t`Select`,
@@ -623,6 +629,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
number: t`Number`,
integer: t`Integer`,
boolean: t`Boolean`,
+ date: t`Date Only`,
datetime: t`Date & Time`,
select: t`Select`,
url: t`URL`,
diff --git a/packages/admin/src/components/RepeaterField.tsx b/packages/admin/src/components/RepeaterField.tsx
index 4afc56811b..166c9e101a 100644
--- a/packages/admin/src/components/RepeaterField.tsx
+++ b/packages/admin/src/components/RepeaterField.tsx
@@ -369,6 +369,16 @@ function SubFieldInput({ subField, value, onChange, timezone }: SubFieldInputPro
required={subField.required}
/>
);
+ case "date":
+ return (
+ onChange(e.target.value)}
+ required={subField.required}
+ />
+ );
case "select": {
// Searchable combobox so long option lists (e.g. taxonomy-derived
// options) stay usable inside repeater rows, rather than a plain
diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts
index d8aa10a1f1..cb51baa21f 100644
--- a/packages/admin/src/lib/api/schema.ts
+++ b/packages/admin/src/lib/api/schema.ts
@@ -14,6 +14,7 @@ export type FieldType =
| "number"
| "integer"
| "boolean"
+ | "date"
| "datetime"
| "select"
| "multiSelect"
diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx
index 0861252db7..f4bd68ec93 100644
--- a/packages/admin/tests/components/ContentEditor.test.tsx
+++ b/packages/admin/tests/components/ContentEditor.test.tsx
@@ -984,6 +984,14 @@ describe("ContentEditor", () => {
await expect.element(input).toHaveAttribute("type", "datetime-local");
});
+ it("renders date fields as date-only inputs", async () => {
+ const screen = await renderEditor({
+ fields: { event_date: { kind: "date", label: "Event date" } },
+ });
+ const input = screen.getByLabelText("Event date");
+ await expect.element(input).toHaveAttribute("type", "date");
+ });
+
it("displays a stored ISO datetime in the datetime-local input", async () => {
// The validator stores datetimes as full ISO 8601 with "Z" + millis,
// but only accepts "YYYY-MM-DDTHH:mm".
@@ -1035,6 +1043,30 @@ describe("ContentEditor", () => {
);
});
+ it("saves date fields back as YYYY-MM-DD without a time", async () => {
+ const onSave = vi.fn();
+ const screen = await renderEditor({
+ isNew: true,
+ onSave,
+ fields: {
+ title: { kind: "string", label: "Title", required: true },
+ event_date: { kind: "date", label: "Event date" },
+ },
+ });
+
+ await screen.getByLabelText("Title").fill("Event");
+ await screen.getByLabelText("Event date").fill("2026-02-26");
+ await screen.getByRole("button", { name: "Save" }).first().click();
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ event_date: "2026-02-26",
+ }),
+ }),
+ );
+ });
+
it("renders json fields as a textarea", async () => {
const screen = await renderEditor({
fields: { metadata: { kind: "json", label: "Metadata" } },
diff --git a/packages/admin/tests/components/FieldEditor.test.tsx b/packages/admin/tests/components/FieldEditor.test.tsx
index 01d32584eb..6468718b3a 100644
--- a/packages/admin/tests/components/FieldEditor.test.tsx
+++ b/packages/admin/tests/components/FieldEditor.test.tsx
@@ -16,6 +16,7 @@ const FIELD_TYPE_REGEXES = [
/Integer Whole number/,
/Boolean True\/false toggle/,
/Date & Time/,
+ /Date Only/,
/^Select Single choice/,
/Multi Select/,
/Rich Text/,
@@ -85,7 +86,7 @@ describe("FieldEditor", () => {
.toBeInTheDocument();
});
- it("shows all 14 field types as buttons", async () => {
+ it("shows all 15 field types as buttons", async () => {
const screen = await render();
// Each type renders as a button with label and description
for (const name of FIELD_TYPE_REGEXES) {
diff --git a/packages/admin/tests/components/RepeaterField.test.tsx b/packages/admin/tests/components/RepeaterField.test.tsx
index 1b8a70f2ae..484541753b 100644
--- a/packages/admin/tests/components/RepeaterField.test.tsx
+++ b/packages/admin/tests/components/RepeaterField.test.tsx
@@ -42,6 +42,41 @@ describe("RepeaterField", () => {
]);
});
});
+
+ describe("date sub-field", () => {
+ it("renders date-only values in a date input", async () => {
+ const screen = await render(
+ ,
+ );
+ const input = screen.getByLabelText("Event date");
+ await expect.element(input).toHaveAttribute("type", "date");
+ await expect.element(input).toHaveValue("2026-02-26");
+ });
+
+ it("emits YYYY-MM-DD without a time on change", async () => {
+ const onChange = vi.fn();
+ const screen = await render(
+ ,
+ );
+ await screen.getByLabelText("Event date").fill("2026-02-26");
+
+ expect(onChange).toHaveBeenLastCalledWith([
+ expect.objectContaining({ event_date: "2026-02-26" }),
+ ]);
+ });
+ });
});
/**
diff --git a/packages/core/src/api/handlers/manifest.ts b/packages/core/src/api/handlers/manifest.ts
index f431d120bb..7cc7eb2321 100644
--- a/packages/core/src/api/handlers/manifest.ts
+++ b/packages/core/src/api/handlers/manifest.ts
@@ -42,6 +42,7 @@ const FIELD_TYPE_TO_KIND: Record = {
number: "number",
integer: "number",
boolean: "boolean",
+ date: "date",
datetime: "datetime",
select: "select",
multiSelect: "multiSelect",
@@ -225,6 +226,9 @@ function extractFieldType(name: string, schema: unknown): FieldDescriptor {
if (schema.isReference) {
return { kind: "reference", label: formatLabel(name) };
}
+ if (schema.isDateOnly) {
+ return { kind: "date", label: formatLabel(name) };
+ }
// Handle standard Zod types
const def = isObject(schema._def) ? schema._def : undefined;
diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts
index aad4a72ae8..d5450b3e82 100644
--- a/packages/core/src/api/schemas/schema.ts
+++ b/packages/core/src/api/schemas/schema.ts
@@ -43,6 +43,7 @@ const fieldTypeValues = z.enum([
"number",
"integer",
"boolean",
+ "date",
"datetime",
"select",
"multiSelect",
@@ -66,6 +67,7 @@ const repeaterSubFieldSchema = z.object({
"number",
"integer",
"boolean",
+ "date",
"datetime",
"select",
"image",
diff --git a/packages/core/src/cli/commands/schema.ts b/packages/core/src/cli/commands/schema.ts
index c7db5e617e..1c90c61cad 100644
--- a/packages/core/src/cli/commands/schema.ts
+++ b/packages/core/src/cli/commands/schema.ts
@@ -160,7 +160,7 @@ const addFieldCommand = defineCommand({
type: {
type: "string",
description:
- "Field type (string, text, number, integer, boolean, datetime, image, reference, portableText, json)",
+ "Field type (string, text, number, integer, boolean, date, datetime, image, reference, portableText, json)",
required: true,
},
label: {
diff --git a/packages/core/src/fields/date.ts b/packages/core/src/fields/date.ts
new file mode 100644
index 0000000000..26701e0769
--- /dev/null
+++ b/packages/core/src/fields/date.ts
@@ -0,0 +1,68 @@
+import { z } from "astro/zod";
+
+import type { FieldDefinition, FieldUIHints } from "./types.js";
+
+const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+
+export interface DateOptions {
+ required?: boolean;
+ min?: string | Date;
+ max?: string | Date;
+ helpText?: string;
+}
+
+function toDateOnly(value: string | Date): string {
+ if (value instanceof Date) {
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+ return value;
+}
+
+function isValidDateOnly(value: string): boolean {
+ if (!DATE_ONLY_PATTERN.test(value)) return false;
+ const parsed = new Date(`${value}T00:00:00.000Z`);
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
+}
+
+/**
+ * Date field - date picker without a time component.
+ */
+export function date(options: DateOptions = {}): FieldDefinition {
+ const min = options.min ? toDateOnly(options.min) : undefined;
+ const max = options.max ? toDateOnly(options.max) : undefined;
+ let dateSchema = z
+ .string()
+ .regex(DATE_ONLY_PATTERN, "Must be a date in YYYY-MM-DD format")
+ .refine(isValidDateOnly, "Invalid date");
+
+ if (min !== undefined) {
+ dateSchema = dateSchema.refine((value) => value >= min, "Date is too early");
+ }
+
+ if (max !== undefined) {
+ dateSchema = dateSchema.refine((value) => value <= max, "Date is too late");
+ }
+
+ const markedSchema = dateSchema as z.ZodTypeAny & { isDateOnly?: true };
+ markedSchema.isDateOnly = true;
+
+ const schema: z.ZodTypeAny = options.required ? markedSchema : markedSchema.optional();
+
+ const ui: FieldUIHints = {
+ widget: "date",
+ helpText: options.helpText,
+ min,
+ max,
+ };
+
+ return {
+ type: "date",
+ columnType: "TEXT",
+ schema,
+ options,
+ ui,
+ };
+}
diff --git a/packages/core/src/fields/index.ts b/packages/core/src/fields/index.ts
index 068a4bcf2f..d5de9e6357 100644
--- a/packages/core/src/fields/index.ts
+++ b/packages/core/src/fields/index.ts
@@ -6,6 +6,7 @@ export { integer } from "./integer.js";
export { boolean } from "./boolean.js";
export { select } from "./select.js";
export { multiSelect } from "./multiselect.js";
+export { date } from "./date.js";
export { datetime } from "./datetime.js";
export { slug } from "./slug.js";
export { image } from "./image.js";
@@ -35,6 +36,7 @@ export type { IntegerOptions } from "./integer.js";
export type { BooleanOptions } from "./boolean.js";
export type { SelectOptions } from "./select.js";
export type { MultiSelectOptions } from "./multiselect.js";
+export type { DateOptions } from "./date.js";
export type { DatetimeOptions } from "./datetime.js";
export type { SlugOptions } from "./slug.js";
export type { FileOptions } from "./file.js";
diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts
index ba76477213..f6ec892811 100644
--- a/packages/core/src/mcp/server.ts
+++ b/packages/core/src/mcp/server.ts
@@ -1924,7 +1924,7 @@ export function createMcpServer(
description:
"Get detailed info about a collection including all field definitions. " +
"Fields describe the data model: name, type (string, text, number, " +
- "boolean, datetime, portableText, image, reference, json, select, " +
+ "boolean, date, datetime, portableText, image, reference, json, select, " +
"multiSelect, slug), constraints, and validation rules. Use this to " +
"understand what data content_create and content_update expect.",
inputSchema: z.object({
@@ -2082,7 +2082,7 @@ export function createMcpServer(
description:
"Add a new field to a collection's schema. This adds a column to the " +
"database table. Field types: string (short text), text (long text), " +
- "number (decimal), integer, boolean, datetime, select (single choice), " +
+ "number (decimal), integer, boolean, date, datetime, select (single choice), " +
"multiSelect (multiple), portableText (rich text), image, file, " +
"reference (link to another collection), json, slug (URL-safe id). " +
"For select/multiSelect, provide choices in validation.options array.",
@@ -2100,6 +2100,7 @@ export function createMcpServer(
"number",
"integer",
"boolean",
+ "date",
"datetime",
"select",
"multiSelect",
diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts
index 990a3489ba..d2e36b92de 100644
--- a/packages/core/src/plugins/manifest-schema.ts
+++ b/packages/core/src/plugins/manifest-schema.ts
@@ -94,6 +94,7 @@ const FIELD_TYPES = [
"number",
"integer",
"boolean",
+ "date",
"datetime",
"select",
"multiSelect",
diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts
index 0d426df949..b4815f488e 100644
--- a/packages/core/src/schema/types.ts
+++ b/packages/core/src/schema/types.ts
@@ -15,6 +15,7 @@ export type FieldType =
| "number"
| "integer"
| "boolean"
+ | "date"
| "datetime"
| "select"
| "multiSelect"
@@ -36,6 +37,7 @@ export const FIELD_TYPES: readonly FieldType[] = [
"number",
"integer",
"boolean",
+ "date",
"datetime",
"select",
"multiSelect",
@@ -79,6 +81,7 @@ export const FIELD_TYPE_TO_COLUMN: Record = {
number: "REAL",
integer: "INTEGER",
boolean: "INTEGER",
+ date: "TEXT",
datetime: "TEXT",
select: "TEXT",
multiSelect: "JSON",
@@ -126,6 +129,7 @@ export interface RepeaterSubField {
| "number"
| "integer"
| "boolean"
+ | "date"
| "datetime"
| "select"
| "image";
@@ -142,6 +146,7 @@ export const REPEATER_SUB_FIELD_TYPES = [
"number",
"integer",
"boolean",
+ "date",
"datetime",
"select",
"image",
diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts
index 91d3e94882..7e900ce479 100644
--- a/packages/core/src/schema/zod-generator.ts
+++ b/packages/core/src/schema/zod-generator.ts
@@ -5,6 +5,7 @@ import type { CollectionWithFields, Field, FieldType, RepeaterSubField } from ".
/** Pattern to split on underscores, hyphens, and spaces for PascalCase conversion */
const PASCAL_CASE_SPLIT_PATTERN = /[_\-\s]+/;
+const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/**
* Generate a Zod schema from a collection's field definitions
@@ -85,6 +86,15 @@ function getBaseSchema(type: FieldType, field: Pick): ZodTy
// produce its standard rejection.
return z.preprocess((v) => (v === 0 || v === 1 ? Boolean(v) : v), z.boolean());
+ case "date":
+ return z
+ .string()
+ .regex(DATE_ONLY_PATTERN, "Must be a date in YYYY-MM-DD format")
+ .refine((value) => {
+ const parsed = new Date(`${value}T00:00:00.000Z`);
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
+ }, "Invalid date");
+
case "datetime":
return z.iso.datetime({ offset: true }).or(z.iso.datetime({ offset: true, precision: -1 }));
@@ -429,6 +439,7 @@ function fieldTypeToTypeScript(field: {
case "text":
case "slug":
case "url":
+ case "date":
case "datetime":
return "string";
diff --git a/packages/core/tests/integration/mcp/schema.test.ts b/packages/core/tests/integration/mcp/schema.test.ts
index 71d185e91b..a3f6fe5931 100644
--- a/packages/core/tests/integration/mcp/schema.test.ts
+++ b/packages/core/tests/integration/mcp/schema.test.ts
@@ -649,6 +649,7 @@ describe("schema_create_field", () => {
["number", "f_number"],
["integer", "f_integer"],
["boolean", "f_bool"],
+ ["date", "f_date"],
["datetime", "f_dt"],
["portableText", "f_portable_text"],
["json", "f_json"],
diff --git a/packages/core/tests/unit/fields/all-fields.test.ts b/packages/core/tests/unit/fields/all-fields.test.ts
index 5ba1588097..932f748c54 100644
--- a/packages/core/tests/unit/fields/all-fields.test.ts
+++ b/packages/core/tests/unit/fields/all-fields.test.ts
@@ -8,6 +8,7 @@ import {
boolean as booleanField,
select,
multiSelect,
+ date,
datetime,
slug,
image,
@@ -210,6 +211,44 @@ describe("Field Types", () => {
});
});
+ describe("date", () => {
+ it("should create date field", () => {
+ const field = date();
+ expect(field.type).toBe("date");
+ expect(field.ui?.widget).toBe("date");
+ });
+
+ it("should validate date-only strings", () => {
+ const field = date({ required: true });
+ expect(() => field.schema.parse("2026-02-26")).not.toThrow();
+ expect(() => field.schema.parse("2026-02-26T09:30:00.000Z")).toThrow();
+ });
+
+ it("should preserve local calendar dates for Date min/max options", () => {
+ const previousTimezone = process.env.TZ;
+ process.env.TZ = "Asia/Tokyo";
+
+ try {
+ const field = date({
+ required: true,
+ min: new Date(2026, 1, 26),
+ max: new Date(2026, 1, 28),
+ });
+
+ expect(field.ui?.min).toBe("2026-02-26");
+ expect(field.ui?.max).toBe("2026-02-28");
+ expect(() => field.schema.parse("2026-02-26")).not.toThrow();
+ expect(() => field.schema.parse("2026-02-25")).toThrow("Date is too early");
+ } finally {
+ if (previousTimezone === undefined) {
+ delete process.env.TZ;
+ } else {
+ process.env.TZ = previousTimezone;
+ }
+ }
+ });
+ });
+
describe("slug", () => {
it("should create slug field", () => {
const field = slug();
diff --git a/packages/core/tests/unit/plugins/field-widgets.test.ts b/packages/core/tests/unit/plugins/field-widgets.test.ts
index c9929ca7d5..2874ca06f1 100644
--- a/packages/core/tests/unit/plugins/field-widgets.test.ts
+++ b/packages/core/tests/unit/plugins/field-widgets.test.ts
@@ -104,7 +104,7 @@ describe("pluginManifestSchema — fieldWidgets", () => {
{
name: "hex",
label: "Hex Input",
- fieldTypes: ["string", "json"],
+ fieldTypes: ["string", "json", "date"],
},
],
}),
diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts
index b7b52e74a4..e7cb9deb42 100644
--- a/packages/core/tests/unit/runtime/manifest-build.test.ts
+++ b/packages/core/tests/unit/runtime/manifest-build.test.ts
@@ -328,6 +328,7 @@ describe("EmDashRuntime.getManifest()", () => {
source: "test",
});
await registry.createField("posts", { slug: "title", label: "Title", type: "string" });
+ await registry.createField("posts", { slug: "event_date", label: "Event Date", type: "date" });
await registry.createField("posts", { slug: "body", label: "Body", type: "json" });
const runtime = buildRuntime(db);
@@ -336,6 +337,7 @@ describe("EmDashRuntime.getManifest()", () => {
const posts = manifest.collections.posts;
expect(posts).toBeDefined();
expect(posts?.fields.title?.kind).toBe("string");
+ expect(posts?.fields.event_date?.kind).toBe("date");
expect(posts?.fields.body?.kind).toBe("json");
});
diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts
index 90d03174af..a4b4e149ee 100644
--- a/packages/core/tests/unit/schema/registry.test.ts
+++ b/packages/core/tests/unit/schema/registry.test.ts
@@ -743,6 +743,7 @@ describe("SchemaRegistry", () => {
{ type: "number", slug: "f_number", expected: "REAL" },
{ type: "integer", slug: "f_integer", expected: "INTEGER" },
{ type: "boolean", slug: "f_boolean", expected: "INTEGER" },
+ { type: "date", slug: "f_date", expected: "TEXT" },
{ type: "datetime", slug: "f_datetime", expected: "TEXT" },
{ type: "portableText", slug: "f_portable", expected: "JSON" },
{ type: "json", slug: "f_json", expected: "JSON" },
diff --git a/packages/core/tests/unit/schema/zod-generator.test.ts b/packages/core/tests/unit/schema/zod-generator.test.ts
index c2c410c4d2..42c32c2ddc 100644
--- a/packages/core/tests/unit/schema/zod-generator.test.ts
+++ b/packages/core/tests/unit/schema/zod-generator.test.ts
@@ -229,6 +229,32 @@ describe("Zod Generator", () => {
expect(schema.safeParse("https://other.example/path").success).toBe(false);
});
+ it("should generate date schema", () => {
+ const field: Field = {
+ id: "f1",
+ collectionId: "c1",
+ slug: "event_date",
+ label: "Event Date",
+ type: "date",
+ columnType: "TEXT",
+ required: true,
+ unique: false,
+ sortOrder: 0,
+ createdAt: new Date().toISOString(),
+ };
+
+ const schema = generateFieldSchema(field);
+ expect(schema.parse("2026-02-26")).toBe("2026-02-26");
+ expect(() => schema.parse("2026-02-26T09:30:00.000Z")).toThrow();
+ expect(() => schema.parse("02/26/2026")).toThrow();
+
+ const result = schema.safeParse("02/26/2026");
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues[0]?.message).toBe("Must be a date in YYYY-MM-DD format");
+ }
+ });
+
it("should generate select schema with options", () => {
const field: Field = {
id: "f1",
@@ -857,6 +883,7 @@ describe("Zod Generator", () => {
number: "number",
integer: "number",
boolean: "boolean",
+ date: "string",
datetime: "string",
select: "string",
image: MEDIA_LITERAL,