Recipes

Date-Only Fields

Store and edit calendar dates (due dates, birthdays, invoice dates) without timezone shifts

Date-Only Fields

TL;DR: For fields that represent a calendar day (not a moment in time), use <DatePicker asString />. Store the value as YYYY-MM-DD in a Postgres date column.

When You Need This

Anywhere the value is "a day on the calendar" rather than "a moment in time":

  • Due dates, invoice dates, birthdays, contract start/end
  • Postgres date columns (Drizzle returns them as string)

If the field represents a moment ("when was this submitted"), use a regular timestamp — this recipe doesn't apply.


asString Prop on DatePicker

When the underlying field is string | null, tell the picker to read and write YYYY-MM-DD directly:

import { DatePicker } from "@/components/ui/date-picker"

<DatePicker
  asString
  value={invoice.issuedAt}            // string | null — from DB
  onChange={(value) => update({ issuedAt: value })}
/>

This is a discriminated union — TypeScript enforces that value is string | null and onChange receives string | null whenever asString is true. Without asString, both use Date (the old behavior).

Helpers for Server / Manual Use

When you need to convert outside the picker — server-side, integrations, one-off formatting:

import { toDateOnlyString, fromDateOnlyString } from "@/lib/utils/format"

toDateOnlyString(someDate)              // Date → "2026-04-29"
fromDateOnlyString("2026-04-29")        // "2026-04-29" → Date

The Full Checklist

When adding a date-only field to an entity:

#FileWhat to do
1src/lib/db/schema/<entity>.tsUse date() (Postgres date column) — not timestamp()
2src/features/<entity>/validation.tsZod: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) or z.iso.date()
3src/features/<entity>/queries.tsType as string | null — Drizzle returns date columns as strings
4UI forms<DatePicker asString value={...} onChange={...} />
5Server comparisonsWrap with fromDateOnlyString(str) before passing to gte / lte on a timestamp column

Examples

React Hook Form

import { Controller } from "react-hook-form"
import { DatePicker } from "@/components/ui/date-picker"

<Controller
  control={form.control}
  name="dueDate"
  render={({ field }) => (
    <DatePicker
      asString
      value={field.value}
      onChange={field.onChange}
      placeholder="Pick a due date"
    />
  )}
/>

Inline Edit in a DataTable

import { DataTableCellEditDate } from "@/components/data-table"

{
  accessorKey: "deliveryDate",
  cell: ({ row, table }) => {
    const meta = table.options.meta as { onUpdate?: (id: string, data: any) => Promise<void> }
    return (
      <DataTableCellEditDate
        value={row.original.deliveryDate}
        onSave={(val) => meta?.onUpdate?.(row.original.id, { deliveryDate: val })}
      />
    )
  },
}

DataTableCellEditDate uses string mode under the hood — no extra configuration.

Filtering by a Date Column

Date filters store their range as YYYY-MM-DD..YYYY-MM-DD. If the column you're filtering is a timestamp, convert both ends with fromDateOnlyString:

import { gte, lte } from "drizzle-orm"
import { parseDateRange } from "@/lib/url-state/filters"
import { fromDateOnlyString } from "@/lib/utils/format"

if (params.issuedAt?.[0]) {
  const { from, to } = parseDateRange(params.issuedAt[0])
  if (from) conditions.push(gte(invoices.issuedAt, fromDateOnlyString(from)))
  if (to) conditions.push(lte(invoices.issuedAt, fromDateOnlyString(to)))
}

If the column is itself a date column, pass the YYYY-MM-DD string through — Drizzle handles it.


Common Mistakes

Don't mix modes on the same field:

// ❌ Wrong — value is a string, but picker expects Date
<DatePicker value={invoice.issuedAt} onChange={setValue} />

// ✅ Correct
<DatePicker asString value={invoice.issuedAt} onChange={setValue} />

Don't convert dates with .toISOString().split("T")[0]:

// ❌ Wrong — in any timezone east of UTC this returns the previous day
const dateOnly = date.toISOString().split("T")[0]

// ✅ Correct
const dateOnly = toDateOnlyString(date)

Don't parse bare YYYY-MM-DD with new Date:

// ❌ Wrong — parsed as UTC midnight, rolls backward east of UTC
new Date("2026-04-29")

// ✅ Correct
fromDateOnlyString("2026-04-29")

On this page