How to Migrate from Moment.js to date-fns or Day.js
How do I migrate from Moment.js to date-fns or Day.js?
TL;DR
- Bottom line: Moment.js is officially in maintenance mode ("done, not dead"). Replace it with date-fns v4.3 (functional, tree-shakeable, ~13 KB tree-shaken; ~40M weekly downloads) or Day.js 1.11.x (chainable Moment-like API, ~2 KB core; ~25M weekly downloads) — both are immutable and actively maintained. [src9]
- Key tool/command:
npm install date-fnsornpm install dayjs— then find-and-replace Moment patterns using the Quick Reference table below. - Watch out for: Format token differences — Moment uses
YYYY-MM-DDbut date-fns usesyyyy-MM-dd(lowercase year/day). Day.js uses the same tokens as Moment. - Works with: date-fns v4.3.0 (Node 18+, all modern browsers), Day.js 1.11.21 (Node 12+, all browsers including IE11 with polyfills).
- Future: The Temporal API reached TC39 Stage 4 at the March 2026 plenary and ships natively in Chrome 144 (Jan 2026) and Firefox 139 (May 2025); Node.js v24 exposes it behind a flag. It is still not cross-browser safe (Safari lags). Plan for Temporal as the long-term target; use date-fns or Day.js today. [src8, src9]
Constraints
- date-fns v4.x requires Node.js 18+ — v3.x works with Node 16+, v2.x with Node 12+
- Never use Moment.js format tokens (
YYYY,DD) in date-fns — they produce silent wrong output or throw errors - Never let date-fns and Moment.js mutate the same Date object in the same scope — complete one migration file at a time
- Day.js plugins must be registered with
dayjs.extend()before calling plugin features — unregistered calls throw TypeError - date-fns wildcard import (
import * as dateFns) defeats tree-shaking — always use named imports - Temporal API (Chrome 144+, Firefox 139+) is not yet cross-browser safe — do not use as a Moment.js replacement in production targeting Safari or older browsers
Quick Reference
| Moment.js Pattern | date-fns Equivalent | Day.js Equivalent |
|---|---|---|
moment() | new Date() | dayjs() |
moment('2026-02-23') | parseISO('2026-02-23') | dayjs('2026-02-23') |
moment('12-25-2026', 'MM-DD-YYYY') | parse('12-25-2026', 'MM-dd-yyyy', new Date()) | dayjs('12-25-2026', 'MM-DD-YYYY') (requires customParseFormat plugin) |
moment().format('YYYY-MM-DD') | format(new Date(), 'yyyy-MM-dd') | dayjs().format('YYYY-MM-DD') |
moment().format('MMMM Do, YYYY') | format(new Date(), 'MMMM do, yyyy') | dayjs().format('MMMM Do, YYYY') (requires advancedFormat plugin) |
moment().add(7, 'days') | addDays(new Date(), 7) | dayjs().add(7, 'day') |
moment().subtract(1, 'month') | subMonths(new Date(), 1) | dayjs().subtract(1, 'month') |
moment().startOf('month') | startOfMonth(new Date()) | dayjs().startOf('month') |
moment().endOf('month') | endOfMonth(new Date()) | dayjs().endOf('month') |
moment().diff(other, 'days') | differenceInDays(new Date(), other) | dayjs().diff(other, 'day') |
moment().isBefore(other) | isBefore(new Date(), other) | dayjs().isBefore(other) |
moment().isAfter(other) | isAfter(new Date(), other) | dayjs().isAfter(other) |
moment().isSame(other, 'day') | isSameDay(new Date(), other) | dayjs().isSame(other, 'day') |
moment().fromNow() | formatDistance(new Date(), Date.now(), { addSuffix: true }) | dayjs().fromNow() (requires relativeTime plugin) |
moment().isValid() | isValid(parseISO(str)) | dayjs(str).isValid() |
Decision Tree
START
├── Need a Moment.js-compatible chainable API?
│ ├── YES → Use Day.js (near-identical API, 2 KB, plugin-based features)
│ └── NO ↓
├── Need tree-shaking / minimal bundle size?
│ ├── YES → Use date-fns (import only the functions you need, best tree-shaking)
│ └── NO ↓
├── Need first-class time zone support built in?
│ ├── YES → Use date-fns v4 (@date-fns/tz) or Day.js (dayjs/plugin/utc + dayjs/plugin/timezone)
│ └── NO ↓
├── Migrating a large codebase with many moment() calls?
│ ├── YES → Day.js (smallest API surface change — often just rename import)
│ └── NO ↓
├── Prefer functional programming style (no method chaining)?
│ ├── YES → Use date-fns (pure functions, immutable by design)
│ └── NO ↓
├── Targeting only Chrome 144+ / Firefox 139+?
│ ├── YES → Consider Temporal API (native, zero-dependency) with date-fns/Day.js fallback
│ └── NO ↓
└── DEFAULT → date-fns for new projects, Day.js for quick drop-in replacements
Decision Logic
If the team wants the least painful drop-in with a Moment-compatible chainable API
→ Migrate to Day.js 1.11.x — identical format tokens and method names mean most call sites only need the import renamed; add plugins (customParseFormat, advancedFormat, relativeTime, utc, timezone) for parity. [src4, src9]
If client-side bundle size is the primary driver
→ Migrate to date-fns v4.3 with named imports (~13 KB tree-shaken for a typical app) or Day.js core (~2 KB + opt-in plugins) — both eliminate Moment's ~72 KB gzipped footprint. [src3, src9]
If the project needs first-class IANA time-zone support
→ Use date-fns v4 with @date-fns/tz (TZDate) or Day.js with the utc + timezone plugins; test across DST boundaries against the old moment.tz() output before removing Moment. [src3, src4]
If the codebase is large (>500 Moment call sites)
→ Choose Day.js to minimize the API-surface change, build a thin utils/date.js adapter layer first, and migrate file-by-file behind the adapter so the library can be swapped again later. [src5, src7]
If you prefer functional, immutable, TypeScript-first code with no method chaining
→ Use date-fns v4.3 (pure functions, ~40M weekly downloads, full TS types) — never expect mutation; always capture the returned Date. [src3, src6, src9]
If the deployment targets only Chrome 144+ / Firefox 139+ (or Node.js v24 with the flag)
→ Consider the native Temporal API (now TC39 Stage 4) for new code with a date-fns/Day.js fallback; do NOT ship Temporal to Safari or older browsers without the ~40 KB @js-temporal/polyfill. [src8, src9]
If the project is in maintenance mode and bundle size is not a concern
→ Leave Moment.js in place — the Moment team explicitly supports existing production code; migration is optional, not urgent. [src1]
Step-by-Step Guide
1. Audit Moment.js usage in your codebase
Quantify the migration scope by counting Moment.js call sites. This determines whether Day.js (minimal changes) or date-fns (more refactoring, better long-term) is the right target. [src1, src2]
# Count total moment imports/requires
grep -rn "require('moment')\|from 'moment'\|import moment" --include='*.js' --include='*.ts' --include='*.jsx' --include='*.tsx' | wc -l
# Count format() calls (need token conversion for date-fns)
grep -rn '\.format(' --include='*.js' --include='*.ts' | grep -i moment | wc -l
# Count locale usage (affects plugin choices)
grep -rn 'moment\.locale\|\.locale(' --include='*.js' --include='*.ts' | wc -l
# Count timezone usage
grep -rn 'moment\.tz\|moment-timezone' --include='*.js' --include='*.ts' | wc -l
Verify: If timezone count > 0, plan for @date-fns/tz or dayjs/plugin/timezone. If locale count > 0, plan for locale imports.
2. Install the replacement library
Install date-fns or Day.js (or both during a transition period). [src3, src4]
# Option A: date-fns (functional, tree-shakeable)
npm install date-fns
# For timezone support:
npm install @date-fns/tz
# Option B: Day.js (chainable, Moment-like API)
npm install dayjs
Verify: node -e "const { format } = require('date-fns'); console.log(format(new Date(), 'yyyy-MM-dd'))" prints today's date, or node -e "const dayjs = require('dayjs'); console.log(dayjs().format('YYYY-MM-DD'))".
3. Create a date utility wrapper (recommended)
Build a thin adapter layer so the rest of your codebase imports from one file. This isolates the library choice and simplifies future migrations (e.g., to the Temporal API). [src7]
// utils/date.js — adapter layer
import { format, parseISO, addDays, subDays, differenceInDays,
isBefore, isAfter, startOfMonth, endOfMonth } from 'date-fns';
export const formatDate = (date, fmt = 'yyyy-MM-dd') => format(date, fmt);
export const parseDate = (str) => parseISO(str);
export const addDaysTo = (date, n) => addDays(date, n);
export const subDaysFrom = (date, n) => subDays(date, n);
export const daysDiff = (a, b) => differenceInDays(a, b);
export const before = (a, b) => isBefore(a, b);
export const after = (a, b) => isAfter(a, b);
export const monthStart = (date) => startOfMonth(date);
export const monthEnd = (date) => endOfMonth(date);
Verify: Import and call each exported function in a test file to confirm behavior matches your old Moment.js usage.
4. Convert format tokens (date-fns only)
Moment.js and date-fns use different format tokens. Day.js uses the same tokens as Moment, so skip this step if using Day.js. [src2, src6]
// Moment.js → date-fns format token conversion
// YYYY → yyyy (4-digit year)
// YY → yy (2-digit year)
// DD → dd (day of month, zero-padded)
// D → d (day of month)
// Do → do (ordinal day: 1st, 2nd, 3rd)
// dddd → EEEE (full weekday name)
// ddd → EEE (abbreviated weekday name)
// A → a (AM/PM — note: date-fns uses lowercase 'a')
// X → t (Unix timestamp in seconds)
// x → T (Unix timestamp in milliseconds)
// Common patterns:
// 'YYYY-MM-DD' → 'yyyy-MM-dd'
// 'MM/DD/YYYY' → 'MM/dd/yyyy'
// 'MMMM Do, YYYY' → 'MMMM do, yyyy'
// 'dddd, MMMM D YYYY' → 'EEEE, MMMM d yyyy'
// 'hh:mm A' → 'hh:mm a'
// 'HH:mm:ss' → 'HH:mm:ss' (same)
Verify: For each format string in your codebase, compare moment(date).format(oldFmt) output with dateFns.format(date, newFmt).
5. Replace Moment.js calls with the new library
Systematically replace Moment calls file by file. Start with utilities and shared code, then work outward to UI components. [src2, src5]
// BEFORE: Moment.js
import moment from 'moment';
const now = moment();
const formatted = now.format('YYYY-MM-DD');
const nextWeek = moment().add(7, 'days');
const diff = moment('2026-12-31').diff(moment(), 'days');
// AFTER: date-fns
import { format, addDays, differenceInDays, parseISO } from 'date-fns';
const now = new Date();
const formatted = format(now, 'yyyy-MM-dd');
const nextWeek = addDays(new Date(), 7);
const diff = differenceInDays(parseISO('2026-12-31'), new Date());
// AFTER: Day.js
import dayjs from 'dayjs';
const now = dayjs();
const formatted = now.format('YYYY-MM-DD');
const nextWeek = dayjs().add(7, 'day');
const diff = dayjs('2026-12-31').diff(dayjs(), 'day');
Verify: Run your test suite after each file. Compare formatted output strings between old and new implementations.
6. Handle timezone and locale conversions
If your project uses moment-timezone or locale-specific formatting, add the corresponding packages. [src3, src4]
// date-fns: timezone support (v4+)
import { TZDate } from '@date-fns/tz';
import { format } from 'date-fns';
const nyDate = new TZDate(2026, 1, 23, 12, 0, 0, 'America/New_York');
console.log(format(nyDate, 'yyyy-MM-dd HH:mm zzz'));
// date-fns: locale support
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
format(new Date(), 'EEEE, d. MMMM yyyy', { locale: de });
// Day.js: timezone support
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs().tz('America/New_York').format('YYYY-MM-DD HH:mm z');
// Day.js: locale support
import 'dayjs/locale/de';
dayjs.locale('de');
dayjs().format('dddd, D. MMMM YYYY');
Verify: Compare timezone-converted output with the original Moment-timezone results for several known dates across DST boundaries.
7. Remove Moment.js from dependencies
Once all usages are replaced and tests pass, uninstall Moment.js. [src1]
npm uninstall moment moment-timezone
# Verify no remaining references
grep -rn "moment" --include='*.js' --include='*.ts' --include='*.jsx' --include='*.tsx' --include='*.json' | grep -v node_modules | grep -v '.md'
Verify: Build succeeds, all tests pass, and npm ls moment shows no dependencies. Check bundle size reduction.
Code Examples
JavaScript: date-fns migration with full feature coverage
Full script: javascript-date-fns-migration-with-full-feature-co.js (34 lines)
// Input: A utility module that wraps all common Moment.js operations
// Output: The same module rewritten with date-fns v4
import {
format, parseISO, parse,
addDays, addMonths, addYears,
// ... (see full script)
JavaScript: Day.js migration with plugins
Full script: javascript-day-js-migration-with-plugins.js (36 lines)
// Input: A codebase using Moment.js with timezone and relative time
// Output: The same functionality using Day.js with required plugins
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import advancedFormat from 'dayjs/plugin/advancedFormat';
// ... (see full script)
TypeScript: Type-safe date utility with date-fns
Full script: typescript-type-safe-date-utility-with-date-fns.ts (36 lines)
// Input: Need a type-safe date utility layer for a TypeScript project
// Output: Strongly typed wrapper around date-fns functions
import {
format, parseISO, addDays, subDays,
differenceInDays, isBefore, isAfter, isValid
// ... (see full script)
Anti-Patterns
Wrong: Using Moment.js mutability patterns with date-fns
// ❌ BAD — Expecting mutation like Moment.js
const date = new Date('2026-02-23');
addDays(date, 7); // Returns a new Date, does NOT modify 'date'
console.log(date); // Still 2026-02-23, not 2026-03-02!
Correct: Capture the return value
// ✅ GOOD — date-fns returns new Date objects, always assign the result
import { addDays } from 'date-fns';
const date = new Date('2026-02-23');
const nextWeek = addDays(date, 7); // New Date: 2026-03-02
console.log(nextWeek); // 2026-03-02
console.log(date); // 2026-02-23 (unchanged — immutable)
Wrong: Using Moment.js format tokens with date-fns
// ❌ BAD — Moment.js tokens in date-fns produce wrong output
import { format } from 'date-fns';
format(new Date(), 'YYYY-MM-DD');
// Throws error or produces unexpected output — 'YYYY' and 'DD' are not valid date-fns tokens
Correct: Use date-fns format tokens
// ✅ GOOD — date-fns uses lowercase year/day tokens
import { format } from 'date-fns';
format(new Date(), 'yyyy-MM-dd'); // "2026-02-23"
// Key differences: YYYY→yyyy, DD→dd, dddd→EEEE, Do→do, A→a
Wrong: Importing all of date-fns
// ❌ BAD — Defeats the purpose of tree-shaking, imports entire library
import * as dateFns from 'date-fns';
dateFns.format(new Date(), 'yyyy-MM-dd');
// Bundle includes all 200+ functions (~70 KB)
Correct: Import only what you need
// ✅ GOOD — Tree-shakeable named imports
import { format, addDays, parseISO } from 'date-fns';
format(new Date(), 'yyyy-MM-dd');
// Bundle includes only format, addDays, parseISO (~3-6 KB)
Wrong: Using Day.js features without loading plugins
// ❌ BAD — fromNow() is not available without the relativeTime plugin
import dayjs from 'dayjs';
dayjs().fromNow(); // TypeError: dayjs(...).fromNow is not a function
Correct: Extend Day.js with required plugins first
// ✅ GOOD — Load plugins before using their features
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
dayjs().fromNow(); // "a few seconds ago"
Wrong: Chaining Day.js without realizing it returns new instances
// ❌ BAD — Assuming Day.js mutates like Moment.js
const d = dayjs('2026-02-23');
d.add(7, 'day');
console.log(d.format('YYYY-MM-DD')); // Still "2026-02-23"!
// Day.js is immutable — add() returns a new instance
Correct: Chain or capture the new instance
// ✅ GOOD — Day.js returns new instances, just like date-fns returns new Dates
const d = dayjs('2026-02-23');
const nextWeek = d.add(7, 'day');
console.log(nextWeek.format('YYYY-MM-DD')); // "2026-03-02"
// Or chain: dayjs('2026-02-23').add(7, 'day').format('YYYY-MM-DD')
Common Pitfalls
- Format token confusion (date-fns): The most common migration bug. Moment uses
YYYYfor year, date-fns usesyyyy. Using Moment tokens in date-fns causes errors or wrong output. Fix: Use a find-and-replace forYYYY→yyyy,DD→dd,dddd→EEEE,Do→do,A→a. [src2] - Missing Day.js plugins: Day.js core is minimal — features like
fromNow(), custom parsing, timezone,isBetween(), and advanced format tokens (Do) all require separate plugins. Fix: Check the Day.js plugin docs; import anddayjs.extend()each plugin at app startup. [src4] - Moment.js mutation assumption: Moment modifies objects in place (
date.add(1, 'day')mutatesdate). Both date-fns and Day.js are immutable. Fix: Always assign the return value:const newDate = addDays(date, 1)orconst newDate = d.add(1, 'day'). [src5, src6] - Bundle size regression with date-fns wildcard import: Using
import * as dateFns from 'date-fns'or the/index.jsentry imports the entire library, negating the tree-shaking advantage. Fix: Use named imports:import { format, addDays } from 'date-fns'. [src3] - Timezone handling differences: Moment-timezone stores timezone data in the library. date-fns v4 uses
@date-fns/tzwithTZDate, Day.js usesdayjs/plugin/timezone. Both use IANA timezone IDs but the APIs differ significantly. Fix: Test across DST boundaries; convert moment.tz() calls individually. [src3, src4] - Locale loading differences: Moment bundles all locales by default (~300 KB). date-fns requires explicit locale imports passed as options. Day.js requires explicit locale file imports. Fix: For date-fns:
import { de } from 'date-fns/locale'; format(date, 'PP', { locale: de }). For Day.js:import 'dayjs/locale/de'; dayjs.locale('de'). [src2] - parseISO vs new Date(): date-fns's
parseISO()strictly parses ISO 8601 strings only. Usingnew Date(str)for non-ISO strings can give inconsistent results across browsers. Fix: Useparse()with explicit format for non-ISO date strings:parse('02/23/2026', 'MM/dd/yyyy', new Date()). [src3] - Day.js diff() uses singular units: Moment uses
diff(other, 'days')(plural), Day.js usesdiff(other, 'day')(singular). Day.js accepts both, but TypeScript types may complain with plurals. Fix: Use singular unit names:'day','month','year'. [src4]
Diagnostic Commands
# Check current Moment.js version and bundle impact
npm ls moment moment-timezone
npx bundlephobia moment # ~290 KB minified, ~72 KB gzipped
# Find all Moment.js imports in the project
grep -rn "require('moment')\|from 'moment'" --include='*.js' --include='*.ts' --include='*.tsx' | wc -l
# Find Moment.js format strings that need token conversion
grep -rn "\.format(" --include='*.js' --include='*.ts' | grep -v node_modules
# Check date-fns bundle size (tree-shaken)
npx bundlephobia date-fns # Full: ~80 KB, but tree-shaking brings it to 3-15 KB typically
# Check Day.js bundle size
npx bundlephobia dayjs # ~2.9 KB minified + gzipped (core only)
# Verify no Moment.js references remain after migration
grep -rn "moment" --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' | grep -v node_modules | grep -v "\.md" | grep -v "// moment"
# Check if Temporal API is available in your Node.js version
node -e "console.log(typeof Temporal !== 'undefined' ? 'Temporal available' : 'Temporal not available')"
Version History & Compatibility
| Library | Version | Status | Key Features | Node.js |
|---|---|---|---|---|
| Moment.js 2.x | Maintenance only | Legacy — no new features, no v3 planned | Mutable API, bundled locales, moment-timezone | Any |
| date-fns 4.3.0 | Current (latest v4.x) | Active | First-class TZ via @date-fns/tz, ESM-first, format/formatISO/formatRFC3339 TZ support, Temporal JSDoc refs | 18+ |
| date-fns 4.1.0 | Sep 2024 | Maintained | Added TZ to format/formatISO/formatRelative | 18+ |
| date-fns 3.x | Previous (Dec 2023) | Maintained | TypeScript rewrite, ESM + CJS dual | 16+ |
| date-fns 2.x | Legacy | Bug fixes only | Original API, separate date-fns-tz | 12+ |
| Day.js 1.11.21 | Current (May 2026) | Active, stable (since 2018) | Moment-compatible API, plugin system, ESM support | 12+ |
| Temporal API | TC39 Stage 4 (Mar 2026) | Chrome 144 (Jan 2026), Firefox 139 (May 2025), Node.js v24 (flag), Safari partial | Native date/time, immutable, timezone-aware, no dependencies | Stabilizing |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Codebase uses Moment.js and needs bundle size reduction | Simple date formatting (1-2 operations) | Native Intl.DateTimeFormat |
| Starting a new project needing date manipulation | Application targets very old browsers without build tools | Moment.js (still works, just large) |
| Need immutable date operations to prevent bugs | Project is in maintenance mode with no new development | Leave Moment.js in place |
| Tree-shaking is important for client-side performance | Need a complete ISO 8601 / RFC 2822 parser | Luxon or Temporal (future) |
| Want functional programming style (choose date-fns) | Team prefers chainable object API | Choose Day.js instead |
| Want Moment-compatible API with minimal changes (choose Day.js) | Need advanced calendar systems (Hebrew, Islamic) | Luxon or Intl API |
| Targeting only Chrome 144+ / Firefox 139+ | Need cross-browser support including Safari | Temporal API (with polyfill) |
Important Caveats
- Moment.js is not "broken" — if your project works and bundle size is not a concern, migration is optional. The Moment team explicitly says existing production code can stay on Moment.
- date-fns v4 has breaking changes in timezone handling compared to v3's
date-fns-tzpackage. If upgrading from v3, review the @date-fns/tz migration guide separately. The v4.1 release added timezone support toformat,formatISO,formatISO9075,formatRelative, andformatRFC3339. - Day.js is largely Moment-compatible but not 100% — edge cases exist around DST transitions, locale-specific ordinals, and strict parsing. The 1.11.x line (current 1.11.21, May 2026) has ESM module support and improved timezone plugin millisecond precision. Always test date-heavy logic after migration.
- The Temporal API reached TC39 Stage 4 at the March 2026 plenary — it is now an approved part of the language. It ships natively in Chrome 144 (January 2026) and Firefox 139 (May 2025); Node.js v24 exposes it behind a flag and Safari support is partial. Temporal will eventually supersede all third-party date libraries, but cross-browser production use still requires the
@js-temporal/polyfillpackage. date-fns has an "Interim API" experiment underway to offer a Temporal-compatible build alongside itsDateAPI. [src8, src9] - Both date-fns and Day.js use native JavaScript Date objects internally, which means they inherit Date's UTC vs local time gotchas. Be explicit about timezone expectations.
- The
@js-temporal/polyfilladds ~40 KB to your bundle — comparable to Moment.js itself. Wait for native browser support to stabilize before adopting Temporal for bundle-size-sensitive applications.