Format a Date as YYYY-MM-DD in JavaScript
To format a date as YYYY-MM-DD for the user's day, read the calendar fields in the intended zone (or use Temporal.PlainDate.toString()) — toISOString().slice(0,10) gives the UTC day, which can be off by one. Part of UTC vs Local Time.
Why this scenario is tricky
Formatting a date as YYYY-MM-DD is the single most common date-formatting task and a startlingly common source of the "off-by-one day" bug. The reason is that the answer depends on which zone's calendar day you mean, and the popular legacy shortcut — date.toISOString().slice(0, 10) — always answers in UTC, regardless of what the user sees. For a user in New York at 9pm, the UTC date is already tomorrow, so slicing the ISO string shows tomorrow's date on a "today" label. The bug is invisible in testing if your test machine happens to be near UTC, which is why it reaches production so often.
The correct framing is that YYYY-MM-DD is a civil date, and to produce it you must first decide which zone's civil day you want, then read that day. Temporal makes this explicit: you project into a zone and read the date, so the zone is a visible parameter rather than a silent default. toISOString().slice(0,10) hides the zone (it is always UTC), which is exactly why it produces surprises. The trick is to never let the zone be implicit when formatting a civil date.
new Date().toISOString().slice(0, 10) is the popular one-liner, but it returns the UTC calendar day. For a user a few hours west of UTC late in the evening, that is tomorrow's date; a few hours east early in the morning, yesterday's. The fix is to format from the fields in the zone you actually mean.
Minimal working solution
Temporal.Now.plainDateISO(zone).toString() produces today's civil date in the given zone, already in YYYY-MM-DD form because that is the canonical string of a PlainDate. There is no slicing and no risk of a time component leaking in, because a PlainDate has no time. Passing the zone explicitly means "today" is the user's today, not the server's, which is what a date label almost always intends. For a user-facing date, pass the user's zone; for a storage-facing UTC date, pass 'UTC' deliberately, so the choice is visible.
From an existing Instant, project into the zone first — instant.toZonedDateTimeISO(zone).toPlainDate().toString() — for the same reason: the instant is a moment, and turning it into a calendar day requires choosing the zone whose midnight defines the day boundary. Keeping that projection explicit is what prevents the UTC-slice bug, because the zone appears in the code and a reviewer can see which day the format will produce.
import { Temporal } from '@js-temporal/polyfill';
// Today's civil date in the user's zone, already YYYY-MM-DD.
const ymd = Temporal.Now.plainDateISO('America/New_York').toString();
Full production version
A production formatter takes the instant (or date) and the zone as explicit inputs and returns the YYYY-MM-DD string, so every call site declares which zone's day it wants. This is worth enforcing because the same underlying moment legitimately formats to different dates for different users — an event at 03:00 UTC is "yesterday" for a user in Los Angeles and "today" for one in London — and the only correct behavior is to format in each user's zone. Centralizing the formatter also gives you one place to decide padding and separator conventions, though PlainDate.toString() already produces the zero-padded ISO form.
Resist the temptation to reach for Intl.DateTimeFormat for this particular task unless you need locale-specific formatting, because its output is locale-dependent and can reorder or localize the parts, whereas YYYY-MM-DD is a fixed machine format. When you genuinely want a localized human date, use Intl and pin the timeZone option so the civil day is deterministic; when you want the ISO YYYY-MM-DD for storage, sorting, or an API, use PlainDate.toString() in the chosen zone. Keeping those two intents separate — machine format versus localized display — avoids both the UTC-slice bug and the surprise of a locale rearranging your date.
// From an existing instant, project into a zone first.
import { Temporal } from '@js-temporal/polyfill';
function toYMD(instant: Temporal.Instant, zone: string): string {
return instant.toZonedDateTimeISO(zone).toPlainDate().toString();
}
Verification snippet
Common pitfalls
The dominant pitfall is date.toISOString().slice(0, 10), which always formats in UTC and shows the wrong day for users whose local date differs from UTC at that moment — the classic off-by-one that appears near midnight. Format in the intended zone with Temporal.Now.plainDateISO(zone) or by projecting the instant into the zone first. The second pitfall is reading local component getters (getFullYear/getMonth/getDate) and hand-assembling the string, which uses the host zone and requires manual zero-padding and a 0-based-month correction that is easy to botch.
A third mistake is using Intl.DateTimeFormat without pinning timeZone, so the civil day depends on the runtime zone and the output varies across environments; pin the zone, or use PlainDate.toString() for a stable ISO string. A fourth is forgetting that YYYY-MM-DD is a machine format and letting a locale-aware formatter reorder it into MM/DD/YYYY or localize the separators; if you need the ISO shape, produce it from a PlainDate rather than an Intl formatter. Keeping the zone explicit and the format intent clear removes all of these.
Frequently Asked Questions
Why is my YYYY-MM-DD date off by one day?
You formatted with toISOString(), which returns the UTC calendar day. For users not in UTC that can be the previous or next day. Format from the date fields in the user's zone, for example Temporal.Now.plainDateISO(zone).toString().
What is the simplest correct way to get YYYY-MM-DD?
If you want today in a zone, Temporal.Now.plainDateISO(zone).toString() already returns YYYY-MM-DD with zero-padding. From an existing instant, project it into the zone with toZonedDateTimeISO(zone) and call toPlainDate().toString().
How do I format a date as YYYY-MM-DD in the user's time zone?
Use Temporal.Now.plainDateISO(zone).toString(), which returns today's civil date in that zone already in YYYY-MM-DD form. From an existing instant, project first: instant.toZonedDateTimeISO(zone).toPlainDate().toString(). Passing the zone explicitly makes 'today' the user's today rather than the server's, avoiding the off-by-one that comes from formatting in UTC.
Why does toISOString().slice(0,10) give the wrong date sometimes?
Because toISOString() always serializes in UTC, so slicing its first ten characters gives the UTC calendar day, not the user's. For a user in New York at 9pm the UTC date is already tomorrow, so the slice shows tomorrow on a 'today' label — the classic off-by-one. The bug hides when your test machine is near UTC. Format in the user's zone with a PlainDate instead.
Should I use Intl.DateTimeFormat to produce YYYY-MM-DD?
Only if you need locale-specific formatting, and then pin the timeZone option so the civil day is deterministic. YYYY-MM-DD is a fixed machine format, and a locale-aware formatter can reorder it to MM/DD/YYYY or localize the separators. For a stable ISO string for storage, sorting, or an API, use PlainDate.toString() in the chosen zone, which always produces the zero-padded ISO shape.