forked from cardosofelipe/fast-next-template
Add internationalization (i18n) with next-intl and Italian translations
- Integrated `next-intl` for server-side and client-side i18n support. - Added English (`en.json`) and Italian (`it.json`) localization files. - Configured routing with locale-based subdirectories (`/[locale]/path`) using `next-intl`. - Introduced type-safe i18n utilities and TypeScript definitions for translation keys. - Updated middleware to handle locale detection and routing. - Implemented dynamic translation loading to reduce bundle size. - Enhanced developer experience with auto-complete and compile-time validation for i18n keys.
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
||||
LogIn,
|
||||
Settings,
|
||||
Users,
|
||||
Lock,
|
||||
Activity,
|
||||
UserCog,
|
||||
BarChart3,
|
||||
|
||||
44
frontend/src/i18n/request.ts
Normal file
44
frontend/src/i18n/request.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/i18n/request.ts
|
||||
/**
|
||||
* Server-side i18n request configuration for next-intl.
|
||||
*
|
||||
* This file handles:
|
||||
* - Loading translation messages for the requested locale
|
||||
* - Server-side locale detection
|
||||
* - Time zone configuration
|
||||
*
|
||||
* Important:
|
||||
* - This runs on the server only (Next.js App Router)
|
||||
* - Translation files are NOT sent to the client (zero bundle overhead)
|
||||
* - Messages are loaded on-demand per request
|
||||
*/
|
||||
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { routing } from './routing';
|
||||
|
||||
export default getRequestConfig(async ({ locale }) => {
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
// Type assertion: we know locale will be a string from the URL parameter
|
||||
const requestedLocale = locale as 'en' | 'it';
|
||||
|
||||
// Check if the requested locale is supported, otherwise use default
|
||||
const validLocale = routing.locales.includes(requestedLocale)
|
||||
? requestedLocale
|
||||
: routing.defaultLocale;
|
||||
|
||||
return {
|
||||
// Return the validated locale
|
||||
locale: validLocale,
|
||||
|
||||
// Load messages for the requested locale
|
||||
// Dynamic import ensures only the requested locale is loaded
|
||||
messages: (await import(`../../messages/${validLocale}.json`)).default,
|
||||
|
||||
// Optional: Configure time zone
|
||||
// This will be used for date/time formatting
|
||||
// timeZone: 'Europe/Rome', // Example for Italian users
|
||||
|
||||
// Optional: Configure now (for relative time formatting)
|
||||
// now: new Date(),
|
||||
};
|
||||
});
|
||||
47
frontend/src/i18n/routing.ts
Normal file
47
frontend/src/i18n/routing.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// src/i18n/routing.ts
|
||||
/**
|
||||
* Internationalization routing configuration for next-intl.
|
||||
*
|
||||
* This file defines:
|
||||
* - Supported locales (en, it)
|
||||
* - Default locale (en)
|
||||
* - Routing strategy (subdirectory pattern: /[locale]/path)
|
||||
*
|
||||
* Architecture Decision:
|
||||
* - Using subdirectory pattern (/en/about, /it/about) for best SEO
|
||||
* - Only 2 languages (EN, IT) as template showcase
|
||||
* - Users can extend by adding more locales to this configuration
|
||||
*/
|
||||
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
import { createNavigation } from 'next-intl/navigation';
|
||||
|
||||
/**
|
||||
* Routing configuration for next-intl.
|
||||
*
|
||||
* Pattern: /[locale]/[pathname]
|
||||
* Examples:
|
||||
* - /en/about
|
||||
* - /it/about
|
||||
* - /en/auth/login
|
||||
* - /it/auth/login
|
||||
*/
|
||||
export const routing = defineRouting({
|
||||
// A list of all locales that are supported
|
||||
locales: ['en', 'it'],
|
||||
|
||||
// Used when no locale matches
|
||||
defaultLocale: 'en',
|
||||
|
||||
// Locale prefix strategy
|
||||
// - "always": Always show locale in URL (/en/about, /it/about)
|
||||
// - "as-needed": Only show non-default locales (/about for en, /it/about for it)
|
||||
// We use "always" for clarity and consistency
|
||||
localePrefix: 'always',
|
||||
});
|
||||
|
||||
// Lightweight wrappers around Next.js' navigation APIs
|
||||
// that will consider the routing configuration
|
||||
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
|
||||
|
||||
export type Locale = (typeof routing.locales)[number];
|
||||
109
frontend/src/lib/i18n/utils.ts
Normal file
109
frontend/src/lib/i18n/utils.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// src/lib/i18n/utils.ts
|
||||
/**
|
||||
* Utility functions for internationalization.
|
||||
*
|
||||
* This file demonstrates type-safe translation usage.
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* Get the display name for a locale code.
|
||||
*
|
||||
* @param locale - The locale code ('en' or 'it')
|
||||
* @returns The human-readable locale name
|
||||
*/
|
||||
export function getLocaleName(locale: string): string {
|
||||
const names: Record<string, string> = {
|
||||
en: 'English',
|
||||
it: 'Italiano',
|
||||
};
|
||||
|
||||
return names[locale] || names.en;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the native display name for a locale code.
|
||||
* This shows the language name in its own language.
|
||||
*
|
||||
* @param locale - The locale code ('en' or 'it')
|
||||
* @returns The native language name
|
||||
*/
|
||||
export function getLocaleNativeName(locale: string): string {
|
||||
const names: Record<string, string> = {
|
||||
en: 'English',
|
||||
it: 'Italiano',
|
||||
};
|
||||
|
||||
return names[locale] || names.en;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the flag emoji for a locale.
|
||||
*
|
||||
* @param locale - The locale code ('en' or 'it')
|
||||
* @returns The flag emoji
|
||||
*/
|
||||
export function getLocaleFlag(locale: string): string {
|
||||
// Map to country flags (note: 'en' uses US flag, could be GB)
|
||||
const flags: Record<string, string> = {
|
||||
en: '🇺🇸', // or '🇬🇧' for British English
|
||||
it: '🇮🇹',
|
||||
};
|
||||
|
||||
return flags[locale] || flags.en;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get common translations.
|
||||
* This demonstrates type-safe usage of useTranslations.
|
||||
*
|
||||
* @returns Object with commonly used translation functions
|
||||
*/
|
||||
export function useCommonTranslations() {
|
||||
const t = useTranslations('common');
|
||||
|
||||
return {
|
||||
loading: () => t('loading'),
|
||||
error: () => t('error'),
|
||||
success: () => t('success'),
|
||||
cancel: () => t('cancel'),
|
||||
save: () => t('save'),
|
||||
delete: () => t('delete'),
|
||||
edit: () => t('edit'),
|
||||
close: () => t('close'),
|
||||
confirm: () => t('confirm'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a relative time string (e.g., "2 hours ago").
|
||||
* This is a placeholder for future implementation with next-intl's date/time formatting.
|
||||
*
|
||||
* @param date - The date to format
|
||||
* @param locale - The locale to use for formatting
|
||||
* @returns Formatted relative time string
|
||||
*/
|
||||
export function formatRelativeTime(date: Date, locale: string = 'en'): string {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return locale === 'it' ? 'proprio ora' : 'just now';
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return locale === 'it'
|
||||
? `${minutes} ${minutes === 1 ? 'minuto' : 'minuti'} fa`
|
||||
: `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return locale === 'it'
|
||||
? `${hours} ${hours === 1 ? 'ora' : 'ore'} fa`
|
||||
: `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
|
||||
} else {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return locale === 'it'
|
||||
? `${days} ${days === 1 ? 'giorno' : 'giorni'} fa`
|
||||
: `${days} ${days === 1 ? 'day' : 'days'} ago`;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { routing } from './i18n/routing';
|
||||
|
||||
// Create next-intl middleware for locale handling
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Block access to /dev routes in production
|
||||
// Block access to /dev routes in production (before locale handling)
|
||||
if (pathname.startsWith('/dev')) {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
@@ -14,9 +19,20 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
// Handle locale routing with next-intl
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: '/dev/:path*',
|
||||
// Match all pathnames except for:
|
||||
// - API routes (/api/*)
|
||||
// - Static files (/_next/*, /favicon.ico, etc.)
|
||||
// - Files in public folder (images, fonts, etc.)
|
||||
matcher: [
|
||||
// Match all pathnames except for
|
||||
'/((?!api|_next|_vercel|.*\\..*).*)',
|
||||
// However, match all pathnames within /api/
|
||||
// that don't end with a file extension
|
||||
'/api/(.*)',
|
||||
],
|
||||
};
|
||||
|
||||
25
frontend/src/types/i18n.d.ts
vendored
Normal file
25
frontend/src/types/i18n.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/types/i18n.d.ts
|
||||
/**
|
||||
* TypeScript type definitions for i18n with next-intl.
|
||||
*
|
||||
* This file configures TypeScript autocomplete for translation keys.
|
||||
* By importing the English messages as the reference type, we get:
|
||||
* - Full autocomplete for all translation keys
|
||||
* - Type safety when using t() function
|
||||
* - Compile-time errors for missing or incorrect keys
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const t = useTranslations('auth.login');
|
||||
* t('title'); // ✅ Autocomplete shows available keys
|
||||
* t('invalid'); // ❌ TypeScript error
|
||||
* ```
|
||||
*/
|
||||
|
||||
type Messages = typeof import('../../messages/en.json');
|
||||
|
||||
declare global {
|
||||
// Use type safe message keys with `next-intl`
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface IntlMessages extends Messages {}
|
||||
}
|
||||
Reference in New Issue
Block a user