Migrate from Moment.js to Temporal API in 30 Minutes

Replace deprecated Moment.js with native Temporal API using AI-assisted refactoring. Handle timezones, date math, and formatting correctly.

Problem: Moment.js is Deprecated and Breaking Modern Builds

Your codebase uses Moment.js for date handling, but it's been deprecated since 2020, adds 67KB to your bundle, and causes tree-shaking issues in modern build tools.

You'll learn:

  • Why Temporal API replaces Moment.js patterns
  • How to migrate common operations with AI assistance
  • Handling timezone edge cases that break silently

Time: 30 min | Level: Intermediate


Why This Happens

Moment.js became deprecated because it's mutable, bloated, and doesn't support modern JavaScript features. The TC39 Temporal proposal (Stage 3, shipping in browsers 2025-2026) provides immutable date handling with proper timezone support.

Common symptoms:

  • Build warnings about deprecated packages
  • Bundle size over 60KB just for dates
  • Timezone bugs in production (DST transitions fail silently)
  • "Moment is not defined" errors after tree-shaking

Current status: Temporal API is available in Chrome 123+, Firefox 128+, Safari 18+. Use @js-temporal/polyfill for older browsers.


Solution

Step 1: Install Temporal Polyfill

# Add polyfill for browser compatibility
npm install @js-temporal/polyfill

# Types for TypeScript projects
npm install --save-dev @js-temporal/polyfill

Expected: Package installed without peer dependency warnings.

If it fails:

  • Error: "Cannot resolve module": Check Node.js version is 18+ (Temporal requires modern JS)
  • Conflict with date-fns: Fine to keep both during migration

Step 2: Set Up Polyfill

// src/temporal-polyfill.ts
import { Temporal } from '@js-temporal/polyfill';

// Make available globally for gradual migration
if (typeof globalThis.Temporal === 'undefined') {
  globalThis.Temporal = Temporal;
}

export { Temporal };
// Import at app entry point
// src/main.ts or src/index.ts
import './temporal-polyfill';

Why this works: Polyfill only loads if browser doesn't support Temporal natively. Global assignment lets you migrate files incrementally.


Step 3: Use AI to Identify Migration Patterns

Create a migration prompt for your AI assistant:

// migration-prompt.md
/**
 * Convert this Moment.js code to Temporal API:
 * 
 * Requirements:
 * - Use Temporal.PlainDate for date-only operations
 * - Use Temporal.ZonedDateTime for timezone-aware operations
 * - Use Temporal.Instant for timestamps
 * - Preserve timezone handling behavior
 * - Keep the same output format
 * 
 * Common patterns:
 * moment() -> Temporal.Now.zonedDateTimeISO()
 * moment.utc() -> Temporal.Now.instant()
 * .format() -> .toString() or .toLocaleString()
 * .add() -> .add({ days: 1 })
 * .diff() -> .until().total('days')
 */

Feed this to Claude/GPT with your code: This context ensures AI understands timezone requirements.


Step 4: Migrate Common Patterns

Creating Dates

// ❌ Moment.js
const now = moment();
const utcNow = moment.utc();
const specificDate = moment('2026-02-15');

// ✅ Temporal API
const now = Temporal.Now.zonedDateTimeISO();
const utcNow = Temporal.Now.instant();
const specificDate = Temporal.PlainDate.from('2026-02-15');

Why different types: PlainDate has no time/timezone, ZonedDateTime respects local time, Instant is always UTC.

Date Math

// ❌ Moment.js (mutable - bugs waiting to happen)
const futureDate = moment().add(7, 'days');
const pastDate = moment().subtract(1, 'month');

// ✅ Temporal API (immutable - safe)
const futureDate = Temporal.Now.plainDateISO().add({ days: 7 });
const pastDate = Temporal.Now.plainDateISO().subtract({ months: 1 });

Critical difference: Moment mutates the original object. Temporal always returns new instances.

Formatting

// ❌ Moment.js
const formatted = moment().format('YYYY-MM-DD HH:mm:ss');
const userFriendly = moment().format('MMMM Do YYYY, h:mm a');

// ✅ Temporal API
const formatted = Temporal.Now.plainDateTimeISO().toString();
// -> "2026-02-15T14:30:00"

const userFriendly = Temporal.Now.zonedDateTimeISO().toLocaleString('en-US', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: 'numeric',
  minute: '2-digit',
  hour12: true
});
// -> "February 15, 2026, 2:30 PM"

AI migration tip: Moment's format strings don't translate directly. Use toLocaleString() with Intl.DateTimeFormat options instead.

Timezone Handling

// ❌ Moment.js (fragile with moment-timezone)
const nyTime = moment.tz('2026-02-15 14:30', 'America/New_York');
const converted = nyTime.clone().tz('Europe/London');

// ✅ Temporal API (built-in, robust)
const nyTime = Temporal.ZonedDateTime.from({
  timeZone: 'America/New_York',
  year: 2026,
  month: 2,
  day: 15,
  hour: 14,
  minute: 30
});

const converted = nyTime.withTimeZone('Europe/London');
// Automatically handles DST transitions

Why this matters: Temporal correctly handles DST transitions, leap seconds, and calendar quirks that break Moment.

Calculating Differences

// ❌ Moment.js
const start = moment('2026-01-01');
const end = moment('2026-02-15');
const daysDiff = end.diff(start, 'days');

// ✅ Temporal API
const start = Temporal.PlainDate.from('2026-01-01');
const end = Temporal.PlainDate.from('2026-02-15');
const daysDiff = start.until(end).total({ unit: 'day' });
// -> 45

Gotcha: until() returns a Duration object. Call .total() to get numeric value.


Step 5: AI-Assisted Bulk Migration

Use this workflow with Claude or GPT:

// 1. Extract all Moment usage
grep -r "moment(" src/ --include="*.ts" --include="*.tsx" > moment-usage.txt

// 2. Create migration batch prompt
// For each file, ask AI:
"Migrate this file from Moment.js to Temporal API. 
File: src/utils/dateHelpers.ts
Original code: [paste code]
Use the migration patterns from Step 4."

// 3. Verify each AI suggestion
npm run test -- dateHelpers.test.ts

Pro tip: Migrate one utility file at a time. Test before moving to the next.


Step 6: Handle Edge Cases

// Edge Case 1: Relative time ("2 hours ago")
// Moment.js: moment().fromNow()
// Temporal: No built-in helper, use Intl.RelativeTimeFormat

function timeAgo(date: Temporal.ZonedDateTime): string {
  const now = Temporal.Now.zonedDateTimeISO(date.timeZone);
  const duration = date.until(now);
  const hours = duration.total({ unit: 'hour' });
  
  const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
  
  if (hours < 1) {
    const minutes = duration.total({ unit: 'minute' });
    return formatter.format(-Math.floor(minutes), 'minute');
  }
  if (hours < 24) {
    return formatter.format(-Math.floor(hours), 'hour');
  }
  const days = duration.total({ unit: 'day' });
  return formatter.format(-Math.floor(days), 'day');
}

// Usage
const past = Temporal.ZonedDateTime.from('2026-02-15T12:00[America/New_York]');
console.log(timeAgo(past)); // "2 hours ago"
// Edge Case 2: Start/End of day
// Moment.js: moment().startOf('day')
// Temporal: Use PlainDate + conversion

const date = Temporal.Now.zonedDateTimeISO();
const startOfDay = date.toPlainDate().toZonedDateTime({
  timeZone: date.timeZone,
  plainTime: Temporal.PlainTime.from('00:00:00')
});

const endOfDay = date.toPlainDate().toZonedDateTime({
  timeZone: date.timeZone,
  plainTime: Temporal.PlainTime.from('23:59:59')
});

If AI suggests wrong patterns:

  • "Use .toInstant() for dates": Wrong - loses timezone info, use ZonedDateTime
  • "Call .getTime()": Temporal doesn't have this, use .epochMilliseconds
  • "Format with moment format strings": Use Intl.DateTimeFormat options instead

Verification

Test 1: Bundle Size

# Before migration
npm run build
# Check bundle size in dist/

# After migration (remove moment)
npm uninstall moment moment-timezone
npm run build

You should see: 60-70KB reduction in bundle size.

Test 2: Timezone Accuracy

// Test DST transition (March 10, 2026 - US DST starts)
import { Temporal } from '@js-temporal/polyfill';

const beforeDST = Temporal.ZonedDateTime.from(
  '2026-03-08T12:00[America/New_York]'
);
const afterDST = beforeDST.add({ days: 3 });

console.log(beforeDST.offset); // "-05:00" (EST)
console.log(afterDST.offset);  // "-04:00" (EDT) - correct!

// Moment.js often gets this wrong with .add()

Test 3: Run Existing Date Tests

npm run test -- --grep="date|time|moment"

You should see: All tests passing. If tests fail, check:

  • Mutable vs immutable behavior (Temporal never mutates)
  • Format string mismatches (use toLocaleString())
  • Timezone not specified (add explicit timeZone parameter)

What You Learned

  • Temporal API provides three types: PlainDate (no time), ZonedDateTime (with timezone), Instant (UTC timestamp)
  • All Temporal operations are immutable - no accidental mutations
  • AI assistants can migrate patterns but need explicit timezone requirements
  • Polyfill adds ~30KB but enables gradual migration

Limitations:

  • Older browsers need polyfill (adds bundle size temporarily)
  • No drop-in replacement - requires understanding date type distinctions
  • AI may suggest .getTime() patterns that don't exist in Temporal

When NOT to migrate:

  • If you're on Node.js <18 and can't upgrade (use date-fns instead)
  • If you need relative time formatting (Temporal delegates to Intl.RelativeTimeFormat)
  • If your team isn't ready to learn immutable patterns

Quick Reference

Moment.jsTemporal API
moment()Temporal.Now.zonedDateTimeISO()
moment.utc()Temporal.Now.instant()
moment('2026-02-15')Temporal.PlainDate.from('2026-02-15')
.add(7, 'days').add({ days: 7 })
.format('YYYY-MM-DD').toString() or .toPlainDate().toString()
.diff(other, 'days').until(other).total({ unit: 'day' })
.isBefore(other)Temporal.PlainDate.compare(a, b) < 0
.clone()Not needed - all operations return new instances

AI Migration Checklist

When reviewing AI-generated Temporal code, verify:

  • Correct type used (PlainDate vs ZonedDateTime vs Instant)
  • Timezone explicitly specified for ZonedDateTime
  • .total() called on Duration results
  • No mutation assumptions (no .set(), only .with())
  • Format strings converted to Intl options
  • Tests updated for immutable behavior
  • Polyfill imported in entry point

Tested with @js-temporal/polyfill 0.4.4, TypeScript 5.5, Node.js 22.x, Chrome 123+

Browser compatibility: Native support in Chrome 123+, Firefox 128+, Safari 18+. Use polyfill for older browsers.

Bundle impact: Polyfill adds ~30KB gzipped. Native Temporal adds 0KB. Removing Moment.js saves ~67KB minified.