Documentation Next.js

はじめに

Next.js App Routerでは、サーバーコンポーネントとクライアントコンポーネントで日時フォーマットを扱う際にハイドレーション不一致が発生しやすくなります。この記事では、国際化対応を含めた効率的なフォーマット管理方法を解説します。

Intl APIによる標準フォーマット

日時フォーマット

// lib/formatters.ts
export function formatDate(
  date: Date | string,
  locale: string = 'ja-JP',
  options?: Intl.DateTimeFormatOptions
): string {
  const d = typeof date === 'string' ? new Date(date) : date;

  const defaultOptions: Intl.DateTimeFormatOptions = {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    ...options,
  };

  return new Intl.DateTimeFormat(locale, defaultOptions).format(d);
}

export function formatDateTime(
  date: Date | string,
  locale: string = 'ja-JP'
): string {
  const d = typeof date === 'string' ? new Date(date) : date;

  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  }).format(d);
}

// ISO 8601形式(API用)
export function toISOString(date: Date): string {
  return date.toISOString();
}

// 使用例
formatDate(new Date());
// → "2024年10月24日"

formatDateTime(new Date());
// → "2024年10月24日 14:30"

数値フォーマット

// lib/formatters.ts
export function formatNumber(
  value: number,
  locale: string = 'ja-JP',
  options?: Intl.NumberFormatOptions
): string {
  return new Intl.NumberFormat(locale, options).format(value);
}

export function formatCurrency(
  value: number,
  currency: string = 'JPY',
  locale: string = 'ja-JP'
): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: currency === 'JPY' ? 0 : 2,
    maximumFractionDigits: currency === 'JPY' ? 0 : 2,
  }).format(value);
}

export function formatPercent(
  value: number,
  locale: string = 'ja-JP'
): string {
  return new Intl.NumberFormat(locale, {
    style: 'percent',
    minimumFractionDigits: 1,
    maximumFractionDigits: 1,
  }).format(value);
}

export function formatCompact(
  value: number,
  locale: string = 'ja-JP'
): string {
  return new Intl.NumberFormat(locale, {
    notation: 'compact',
    compactDisplay: 'short',
  }).format(value);
}

// 使用例
formatCurrency(1234567);      // → "¥1,234,567"
formatCurrency(99.99, 'USD', 'en-US'); // → "$99.99"
formatPercent(0.156);         // → "15.6%"
formatCompact(1234567);       // → "123万"
formatNumber(1234567.89);     // → "1,234,567.89"

date-fnsによる高度なフォーマット

基本セットアップ

npm install date-fns date-fns-tz
// lib/date-utils.ts
import { format, formatDistance, formatRelative, parseISO } from 'date-fns';
import { ja, enUS } from 'date-fns/locale';
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';

const locales: Record<string, Locale> = {
  ja,
  en: enUS,
};

export function formatDateWithLocale(
  date: Date | string,
  formatStr: string = 'yyyy年MM月dd日',
  locale: string = 'ja'
): string {
  const d = typeof date === 'string' ? parseISO(date) : date;
  return format(d, formatStr, { locale: locales[locale] || ja });
}

// タイムゾーン対応
export function formatWithTimezone(
  date: Date | string,
  timezone: string,
  formatStr: string = 'yyyy/MM/dd HH:mm zzz'
): string {
  const d = typeof date === 'string' ? parseISO(date) : date;
  return formatInTimeZone(d, timezone, formatStr, { locale: ja });
}

// 相対時間(例:3時間前)
export function formatTimeAgo(
  date: Date | string,
  locale: string = 'ja'
): string {
  const d = typeof date === 'string' ? parseISO(date) : date;
  return formatDistance(d, new Date(), {
    addSuffix: true,
    locale: locales[locale] || ja,
  });
}

// 相対日付(例:昨日 14:30)
export function formatRelativeDate(
  date: Date | string,
  locale: string = 'ja'
): string {
  const d = typeof date === 'string' ? parseISO(date) : date;
  return formatRelative(d, new Date(), {
    locale: locales[locale] || ja,
  });
}

// 使用例
formatDateWithLocale(new Date(), 'yyyy年MM月dd日(E)');
// → "2024年10月24日(木)"

formatWithTimezone(new Date(), 'America/New_York', 'yyyy/MM/dd HH:mm zzz');
// → "2024/10/24 01:30 EDT"

formatTimeAgo(new Date(Date.now() - 3 * 60 * 60 * 1000));
// → "約3時間前"

カスタムフォーマット関数

// lib/date-utils.ts
import {
  startOfDay,
  endOfDay,
  startOfMonth,
  endOfMonth,
  addDays,
  differenceInDays,
  isToday,
  isYesterday,
  isTomorrow,
  isThisWeek,
  isThisMonth,
} from 'date-fns';

// 期間フォーマット
export function formatDateRange(
  start: Date,
  end: Date,
  locale: string = 'ja'
): string {
  const sameYear = start.getFullYear() === end.getFullYear();
  const sameMonth = sameYear && start.getMonth() === end.getMonth();

  if (sameMonth) {
    return `${format(start, 'yyyy年M月d日', { locale: locales[locale] })} 〜 ${format(end, 'd日', { locale: locales[locale] })}`;
  }

  if (sameYear) {
    return `${format(start, 'yyyy年M月d日', { locale: locales[locale] })} 〜 ${format(end, 'M月d日', { locale: locales[locale] })}`;
  }

  return `${formatDateWithLocale(start)} 〜 ${formatDateWithLocale(end)}`;
}

// スマート日付表示
export function formatSmartDate(date: Date | string): string {
  const d = typeof date === 'string' ? parseISO(date) : date;

  if (isToday(d)) {
    return `今日 ${format(d, 'HH:mm')}`;
  }

  if (isYesterday(d)) {
    return `昨日 ${format(d, 'HH:mm')}`;
  }

  if (isTomorrow(d)) {
    return `明日 ${format(d, 'HH:mm')}`;
  }

  if (isThisWeek(d)) {
    return format(d, 'E曜日 HH:mm', { locale: ja });
  }

  if (isThisMonth(d)) {
    return format(d, 'M月d日 HH:mm', { locale: ja });
  }

  return format(d, 'yyyy年M月d日', { locale: ja });
}

// 営業日計算
export function addBusinessDays(date: Date, days: number): Date {
  let result = date;
  let count = 0;

  while (count < days) {
    result = addDays(result, 1);
    const dayOfWeek = result.getDay();
    if (dayOfWeek !== 0 && dayOfWeek !== 6) {
      count++;
    }
  }

  return result;
}

ハイドレーション不一致の解決

問題の原因

サーバーとクライアントでタイムゾーンが異なる場合、日時フォーマットの結果が異なり、ハイドレーション不一致が発生します。

解決方法1: suppressHydrationWarning

// components/FormattedDate.tsx
interface FormattedDateProps {
  date: Date | string;
  format?: string;
}

export function FormattedDate({ date, format = 'PPP' }: FormattedDateProps) {
  return (
    <time
      dateTime={new Date(date).toISOString()}
      suppressHydrationWarning
    >
      {formatDateWithLocale(date, format)}
    </time>
  );
}

解決方法2: クライアントサイドのみでフォーマット

// components/ClientFormattedDate.tsx
'use client';

import { useState, useEffect } from 'react';
import { formatSmartDate } from '@/lib/date-utils';

interface Props {
  date: Date | string;
  fallback?: string;
}

export function ClientFormattedDate({ date, fallback = '...' }: Props) {
  const [formatted, setFormatted] = useState<string>(fallback);

  useEffect(() => {
    setFormatted(formatSmartDate(date));
  }, [date]);

  return (
    <time dateTime={new Date(date).toISOString()}>
      {formatted}
    </time>
  );
}

解決方法3: Server Componentでの統一フォーマット

// components/ServerFormattedDate.tsx
import { formatInTimeZone } from 'date-fns-tz';

interface Props {
  date: Date | string;
  timezone?: string;
}

// サーバーコンポーネント(デフォルト)
export function ServerFormattedDate({
  date,
  timezone = 'Asia/Tokyo'
}: Props) {
  const d = typeof date === 'string' ? new Date(date) : date;

  // 常に指定タイムゾーンでフォーマット
  const formatted = formatInTimeZone(
    d,
    timezone,
    'yyyy年MM月dd日 HH:mm',
    { locale: ja }
  );

  return (
    <time dateTime={d.toISOString()}>
      {formatted}
    </time>
  );
}

相対時間のリアルタイム更新

// components/RelativeTime.tsx
'use client';

import { useState, useEffect } from 'react';
import { formatTimeAgo } from '@/lib/date-utils';

interface Props {
  date: Date | string;
  updateInterval?: number; // ミリ秒
}

export function RelativeTime({ date, updateInterval = 60000 }: Props) {
  const [timeAgo, setTimeAgo] = useState<string>('');

  useEffect(() => {
    const update = () => setTimeAgo(formatTimeAgo(date));

    update();
    const interval = setInterval(update, updateInterval);

    return () => clearInterval(interval);
  }, [date, updateInterval]);

  const isoDate = typeof date === 'string'
    ? date
    : date.toISOString();

  return (
    <time dateTime={isoDate} title={formatDateWithLocale(date)}>
      {timeAgo || '...'}
    </time>
  );
}

Intl.RelativeTimeFormat

// lib/relative-time.ts
type Unit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second';

const DIVISIONS: { amount: number; name: Unit }[] = [
  { amount: 60, name: 'second' },
  { amount: 60, name: 'minute' },
  { amount: 24, name: 'hour' },
  { amount: 7, name: 'day' },
  { amount: 4.34524, name: 'week' },
  { amount: 12, name: 'month' },
  { amount: Number.POSITIVE_INFINITY, name: 'year' },
];

export function formatRelativeTime(
  date: Date | string,
  locale: string = 'ja-JP'
): string {
  const d = typeof date === 'string' ? new Date(date) : date;
  const formatter = new Intl.RelativeTimeFormat(locale, {
    numeric: 'auto',
    style: 'long',
  });

  let duration = (d.getTime() - Date.now()) / 1000;

  for (const division of DIVISIONS) {
    if (Math.abs(duration) < division.amount) {
      return formatter.format(Math.round(duration), division.name);
    }
    duration /= division.amount;
  }

  return formatter.format(Math.round(duration), 'year');
}

// 使用例
formatRelativeTime(new Date(Date.now() - 30000));   // → "30秒前"
formatRelativeTime(new Date(Date.now() - 3600000)); // → "1時間前"
formatRelativeTime(new Date(Date.now() - 86400000)); // → "昨日"

フォーマッターコンポーネント

統合コンポーネント

// components/Formatters.tsx
'use client';

import { useState, useEffect, useMemo } from 'react';
import { formatCurrency, formatNumber, formatPercent } from '@/lib/formatters';
import { formatSmartDate, formatTimeAgo } from '@/lib/date-utils';

// 通貨表示
export function Currency({
  value,
  currency = 'JPY',
  locale = 'ja-JP',
}: {
  value: number;
  currency?: string;
  locale?: string;
}) {
  return <span>{formatCurrency(value, currency, locale)}</span>;
}

// 数値表示
export function Number({
  value,
  locale = 'ja-JP',
  options,
}: {
  value: number;
  locale?: string;
  options?: Intl.NumberFormatOptions;
}) {
  return <span>{formatNumber(value, locale, options)}</span>;
}

// パーセント表示
export function Percent({
  value,
  locale = 'ja-JP',
}: {
  value: number;
  locale?: string;
}) {
  return <span>{formatPercent(value, locale)}</span>;
}

// 日時表示(ハイドレーション対応)
export function DateTime({
  value,
  format = 'smart',
}: {
  value: Date | string;
  format?: 'smart' | 'relative' | 'full';
}) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  const formatted = useMemo(() => {
    if (!mounted) return '...';

    switch (format) {
      case 'relative':
        return formatTimeAgo(value);
      case 'full':
        return formatDateWithLocale(value, 'yyyy年MM月dd日 HH:mm:ss');
      case 'smart':
      default:
        return formatSmartDate(value);
    }
  }, [value, format, mounted]);

  const isoDate = typeof value === 'string'
    ? value
    : value.toISOString();

  return (
    <time dateTime={isoDate} suppressHydrationWarning>
      {formatted}
    </time>
  );
}

使用例

// app/products/[id]/page.tsx
import { Currency, Percent, DateTime } from '@/components/Formatters';
import { RelativeTime } from '@/components/RelativeTime';

interface Product {
  id: string;
  name: string;
  price: number;
  discount: number;
  createdAt: string;
  updatedAt: string;
}

async function getProduct(id: string): Promise<Product> {
  const res = await fetch(`${process.env.API_URL}/products/${id}`);
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const product = await getProduct(params.id);

  return (
    <article>
      <h1>{product.name}</h1>

      <dl>
        <dt>価格</dt>
        <dd>
          <Currency value={product.price} />
        </dd>

        <dt>割引率</dt>
        <dd>
          <Percent value={product.discount} />
        </dd>

        <dt>登録日</dt>
        <dd>
          <DateTime value={product.createdAt} format="full" />
        </dd>

        <dt>更新</dt>
        <dd>
          <RelativeTime date={product.updatedAt} />
        </dd>
      </dl>
    </article>
  );
}

国際化対応

// lib/i18n-formatters.ts
type SupportedLocale = 'ja' | 'en' | 'zh';

const localeConfig: Record<SupportedLocale, {
  dateFormat: string;
  currency: string;
  timezone: string;
}> = {
  ja: {
    dateFormat: 'yyyy年MM月dd日',
    currency: 'JPY',
    timezone: 'Asia/Tokyo',
  },
  en: {
    dateFormat: 'MMMM d, yyyy',
    currency: 'USD',
    timezone: 'America/New_York',
  },
  zh: {
    dateFormat: 'yyyy年MM月dd日',
    currency: 'CNY',
    timezone: 'Asia/Shanghai',
  },
};

export function createFormatter(locale: SupportedLocale) {
  const config = localeConfig[locale];

  return {
    date: (date: Date | string) =>
      formatDateWithLocale(date, config.dateFormat, locale),

    currency: (value: number) =>
      formatCurrency(value, config.currency, locale),

    dateTime: (date: Date | string) =>
      formatWithTimezone(date, config.timezone),
  };
}

// 使用例
const formatter = createFormatter('ja');
formatter.date(new Date());     // → "2024年10月24日"
formatter.currency(1000);       // → "¥1,000"

まとめ

用途推奨方法
基本フォーマットIntl API
複雑な日付操作date-fns + date-fns-tz
相対時間Intl.RelativeTimeFormat
ハイドレーション対応suppressHydrationWarning or useEffect
タイムゾーン対応formatInTimeZone

参考文献

円