v2.2.1
v2.2.1 · Zero Hydration Errors · ICU Compliant

The universal
formatter for Next.js

Unified number, date, and currency formatting across every App Router boundary. Consistent between Server and Client, always.

$ npm install next-formatter
Read the docs

Multi-boundary

Works identically in RSC, Server Actions, and Client Hooks — zero extra config.

Smart Locales

Resolves from request headers or cookies. Fully async by default.

Safe ICU Hydration

Prevents narrow-space and currency symbol mismatches between renders.

Installation

Install the package and wrap your root layout with the provider. Define config once, use it everywhere.

lib/formatter.ts
ts
import { getFormatter as _getFormatter } from "next-formatter/server";
import type { NextFormatterConfig } from "next-formatter";

const config: NextFormatterConfig = {
  locale: "en-US",
  currency: "USD",
  rules: {
    compactThreshold: 10000,
    minimumFractionDigits: 0,
    maximumFractionDigits: 2
  }
};

export const formatterConfig = config;
export const getFormatter = () => _getFormatter(config);
app/providers/formatter-provider.tsx
tsx
"use client";
import { FormatterProvider } from "next-formatter/client";
import { formatterConfig } from "@/lib/formatter";
import { ReactNode } from "react";

export function AppFormatterProvider({ children }: { children: ReactNode }) {
  return (
    <FormatterProvider config={formatterConfig}>
      {children}
    </FormatterProvider>
  );
}
app/layout.tsx
tsx
import { AppFormatterProvider } from "@/app/providers/formatter-provider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <AppFormatterProvider>
          {children}
        </AppFormatterProvider>
      </body>
    </html>
  );
}
app/page.tsx
tsx
import { getFormatter } from "@/lib/formatter";

export default async function Page() {
  const { currency } = await getFormatter();
  return <p>{currency(49900)}</p>; // → $49.90K
}
components/Price.tsx
tsx
"use client";
import { useFormatter } from "next-formatter/client";

export function Price({ value }) {
  const { currency } = useFormatter();
  return <span>{currency(value)}</span>;
}

Example Use Cases

Real-world patterns for everywhere from Server Components to background API logic.

Comparison: Choose the right import for your specific architectural needs.
A. Singleton Pattern (Recommended)
Best for 99% of apps. Imports your pre-configured helper with global rules.
import { getFormatter } from "@/lib/formatter";

const fmt = await getFormatter();
fmt.currency(49.99);
B. Factory Pattern (Direct)
Best for background jobs or multi-tenant logic requiring dynamic overrides.
import { getFormatter } from "next-formatter/server";

const fmt = await getFormatter({ locale: "de-DE" });
fmt.currency(49.99);
Where to use: Building dynamic client-side UIs like dashboards, charts, and localized settings.
components/PriceChart.tsx
tsx
"use client";
import { useFormatter } from "next-formatter/client";

export function PriceChart({ data }) {
  const { currency } = useFormatter();
  
  return (
    <Tooltip 
      formatter={(v) => currency(v, { currencyDisplay: "symbol" })} 
    />
  );
}
Where to use: Formatting background logic in Server Actions and localized responses in Route Handlers.
app/api/price/route.ts
ts
import { getFormatter } from "@/lib/formatter";

export async function GET() {
  const { currency } = await getFormatter();
  return Response.json({
    formattedPrice: currency(49.99) // → Auto-detects locale from headers!
  });
}
Where to use: Advanced multi-tenant apps with per-user localized formatting.
lib/formatter.ts
ts
export const formatterConfig: NextFormatterConfig = {
  getLocale: async () => {
    // Read from cookies or database
    return cookies().get("pref-locale")?.value;
  },
  getCurrency: async () => {
    const user = await getCurrentUser();
    return user?.preferredCurrency;
  }
};

API Reference

Four core formatters — each locale-aware, hydration-safe, and boundary-agnostic.

fnfmt.number(value)
1234.567 1,234.57
10500 10.5K
fnfmt.currency(value)
49900 $49.90K
1000000 $1.00M
fnfmt.percentage(value)
12.5 12.50%
0.001 0.001%
fnfmt.date(value)
"2024-01-01" Jan 1, 2024
fnfmt.relativeTime(val, now)
"2024-01-01" 2 months ago
fnfmt.duration(seconds)
150 2m 30s

Configuration

The FormatterProvider accepts a config object that defines global behavior across all server and client boundaries.

Option Type Default Description
locale string "en-US" Fallback locale if detection fails.
currency string "USD" Default ISO-4217 code for currency formatting.
fallback string "—" String rendered for null or undefined values.
rules.compactThreshold number 10000 Value at which compact notation (K, M) is triggered.
rules.minimumFractionDigits number 0 Global min fraction digits (0, 1, 2).
rules.maximumFractionDigits number 2 Global max fraction digits.
rules.currencyDisplay string "narrowSymbol" symbol, narrowSymbol, code, or name.

Hydration & ICU

Native Intl often fails in Next.js because Node.js and Browser ICU engines produce different characters.

next-formatter solves this by normalizing ICU output before the final render.

Unicode Normalization

Converts narrow-no-break-space (U+202F) and non-breaking-space (U+00A0) to standard spaces.

Fraction Alignment

Automatically sets min = max fraction digits when they differ, preventing "54.1" (client) vs "54.10" (server) mismatches.

Strict Stability

Ensures that the server-rendered HTML is byte-for-byte identical to the client's first hydration pass.

Dynamic Resolvers

Detect locale and currency from anywhere: request headers, cookies, or your user database.

lib/formatter.ts
ts
export const formatterConfig: NextFormatterConfig = {
  getLocale: async () => {
    const session = await auth();
    return session?.user?.locale;
  },
  getCurrency: async () => {
    return cookies().get("currency")?.value;
  }
};

Patterns

Consistent formatting across all Server and Client boundaries.

Server Action Example
ts
"use server";
import { getFormatter } from "@/lib/formatter";

export async function checkoutAction(val: number) {
  const fmt = await getFormatter();
  // Perfect for formatted response messages
  return { 
    message: `Processed ${fmt.currency(val)}` 
  };
}

Architecture

Built for Next.js 14+ module isolation. The provider resolves configuration once per request on the server, then distributes it to all boundaries.

Target Method Boundary
Server Components getFormatter() Server
Server Actions getFormatter() Server
Route Handlers getFormatter() Server
Client Components useFormatter() Client

Alternatives

When to use next-formatter vs other solutions.

next-intl

Choose this for full site translation. It's a heavy-duty framework including messages, routing, and formatting.

next-formatter

Choose this for formatting-only. Lightweight, zero-config, and specifically built to fix hydration errors.

Caveat: Only supports the App Router. Pages Router architecture is fundamentally different and not supported.

FAQ

Why does relativeTime need an explicit 'now' on the server?
If the server renders "5 seconds ago" at 12:00:00 and the client hydrator runs at 12:00:01, it would render "6 seconds ago", causing a hydration mismatch. Passing a stable now timestamp from the server prevents this.
Is there any client bundle cost?
The core logic is < 3KB (gzipped). It relies on native Intl to avoid shipping heavy ICU data to your users.