# i18n (next-intl, FR/EN/ES/IT) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add multilingual support (FR, EN, ES, IT) using next-intl, with URL-based locale routing (`/fr`, `/en`, `/es`, `/it`), auto-detection via `Accept-Language`, and a flag emoji switcher on all pages. **Architecture:** A Next.js middleware intercepts all non-API requests and redirects to the locale-prefixed URL detected from the browser's `Accept-Language` header (defaulting to `en`). All UI pages move under a `[locale]` dynamic segment. Components access translations via next-intl's `useTranslations`/`getTranslations` hooks backed by per-locale JSON files in `messages/`. **Tech Stack:** next-intl (latest), Next.js 16 App Router, React 19 ## Global Constraints - Default locale (fallback): `en` - Supported locales: `en`, `fr`, `es`, `it` - Flags: 🇺🇸 (en), 🇫🇷 (fr), 🇪🇸 (es), 🇮🇹 (it) - next-intl is pure JS — no native binaries, compatible with o2switch shared hosting - API routes (`/api/*`) must NOT be locale-prefixed — middleware must exclude them - No changes to `src/lib/`, `src/app/api/`, `worker/`, Prisma schema, or existing tests --- ### Task 1: Install next-intl and create i18n configuration **Files:** - Create: `src/i18n/routing.ts` - Create: `src/i18n/request.ts` - Create: `src/navigation.ts` - Modify: `next.config.ts` - Modify: `package.json` + `package-lock.json` (via npm install) **Interfaces:** - Produces: `routing` exported from `src/i18n/routing.ts` — used by middleware, layout, and LanguageSwitcher - Produces: `useRouter`, `usePathname`, `Link` locale-aware hooks from `src/navigation.ts` — used by SubmitForm and LanguageSwitcher - [ ] **Step 1: Install next-intl** ```bash npm install next-intl ``` - [ ] **Step 2: Create `src/i18n/routing.ts`** ```typescript import { defineRouting } from 'next-intl/routing' export const routing = defineRouting({ locales: ['en', 'fr', 'es', 'it'], defaultLocale: 'en', }) ``` - [ ] **Step 3: Create `src/i18n/request.ts`** ```typescript import { getRequestConfig } from 'next-intl/server' import { routing } from './routing' export default getRequestConfig(async ({ requestLocale }) => { let locale = await requestLocale if (!locale || !(routing.locales as readonly string[]).includes(locale)) { locale = routing.defaultLocale } return { locale, messages: (await import(`../../messages/${locale}.json`)).default, } }) ``` - [ ] **Step 4: Create `src/navigation.ts`** ```typescript import { createNavigation } from 'next-intl/navigation' import { routing } from './i18n/routing' export const { Link, redirect, usePathname, useRouter } = createNavigation(routing) ``` - [ ] **Step 5: Replace `next.config.ts`** ```typescript import type { NextConfig } from 'next' import createNextIntlPlugin from 'next-intl/plugin' const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts') const nextConfig: NextConfig = {} export default withNextIntl(nextConfig) ``` - [ ] **Step 6: Commit** ```bash git add src/i18n/routing.ts src/i18n/request.ts src/navigation.ts next.config.ts package.json package-lock.json git commit -m "feat: install next-intl and set up i18n routing config" ``` --- ### Task 2: Add middleware for locale detection and redirect **Files:** - Create: `src/middleware.ts` **Interfaces:** - Consumes: `routing` from `src/i18n/routing.ts` - Produces: middleware that reads `Accept-Language`, redirects `/` → `/en` (or detected locale), passes `/api/*` and static assets through unchanged - [ ] **Step 1: Create `src/middleware.ts`** ```typescript import createMiddleware from 'next-intl/middleware' import { routing } from './i18n/routing' export default createMiddleware(routing) export const config = { matcher: [ '/((?!api|_next|_vercel|.*\\..*).*)', ], } ``` - [ ] **Step 2: Commit** ```bash git add src/middleware.ts git commit -m "feat: add locale-detection middleware" ``` --- ### Task 3: Create translation message files **Files:** - Create: `messages/en.json` - Create: `messages/fr.json` - Create: `messages/es.json` - Create: `messages/it.json` **Interfaces:** - Produces: namespaces `meta`, `home`, `status` consumed by `getTranslations`/`useTranslations` in Tasks 4 and 5 - Key names must match exactly: `meta.title`, `home.heading`, `home.urlLabel`, `home.format`, `home.quality`, `home.subtitles`, `home.advanced`, `home.submit`, `home.submitting`, `home.errorRateLimit`, `home.errorGeneric`, `status.heading`, `status.notFound`, `status.loading`, `status.pending`, `status.processing`, `status.polling`, `status.ready`, `status.download`, `status.linkExpires`, `status.failed`, `status.deleted`, `status.unknown` - [ ] **Step 1: Create `messages/en.json`** ```json { "meta": { "title": "Ombrora — Video downloader" }, "home": { "heading": "Ombrora", "urlLabel": "Video URL", "format": "Format", "quality": "Quality", "subtitles": "Download subtitles (fr, en)", "advanced": "Advanced options (optional, JSON)", "submit": "Download", "submitting": "Sending...", "errorRateLimit": "Too many submissions. Try again in an hour.", "errorGeneric": "Submission error." }, "status": { "heading": "Download status", "notFound": "Download not found.", "loading": "Loading...", "pending": "Pending", "processing": "Processing...", "polling": "This page refreshes automatically every 5 seconds.", "ready": "Download ready", "download": "Download", "linkExpires": "Link valid until", "failed": "Download failed.", "deleted": "The file has expired and was deleted. The download is no longer available.", "unknown": "Unknown status" } } ``` - [ ] **Step 2: Create `messages/fr.json`** ```json { "meta": { "title": "Ombrora — Téléchargeur de vidéos" }, "home": { "heading": "Ombrora", "urlLabel": "URL de la vidéo", "format": "Format", "quality": "Qualité", "subtitles": "Télécharger les sous-titres (fr, en)", "advanced": "Options avancées (optionnel, JSON)", "submit": "Télécharger", "submitting": "Envoi...", "errorRateLimit": "Trop de soumissions. Réessayez dans une heure.", "errorGeneric": "Erreur lors de la soumission." }, "status": { "heading": "Statut du téléchargement", "notFound": "Téléchargement introuvable.", "loading": "Chargement...", "pending": "En attente", "processing": "En cours...", "polling": "Cette page se rafraîchit automatiquement toutes les 5 secondes.", "ready": "Téléchargement prêt", "download": "Télécharger", "linkExpires": "Lien valable jusqu'au", "failed": "Échec du téléchargement.", "deleted": "Le fichier a expiré et a été supprimé. Le téléchargement n'est plus disponible.", "unknown": "Statut inconnu" } } ``` - [ ] **Step 3: Create `messages/es.json`** ```json { "meta": { "title": "Ombrora — Descargador de vídeos" }, "home": { "heading": "Ombrora", "urlLabel": "URL del vídeo", "format": "Formato", "quality": "Calidad", "subtitles": "Descargar subtítulos (fr, en)", "advanced": "Opciones avanzadas (opcional, JSON)", "submit": "Descargar", "submitting": "Enviando...", "errorRateLimit": "Demasiadas peticiones. Inténtalo de nuevo en una hora.", "errorGeneric": "Error al enviar." }, "status": { "heading": "Estado de la descarga", "notFound": "Descarga no encontrada.", "loading": "Cargando...", "pending": "Pendiente", "processing": "En proceso...", "polling": "Esta página se actualiza automáticamente cada 5 segundos.", "ready": "Descarga lista", "download": "Descargar", "linkExpires": "Enlace válido hasta", "failed": "Error en la descarga.", "deleted": "El archivo ha expirado y fue eliminado. La descarga ya no está disponible.", "unknown": "Estado desconocido" } } ``` - [ ] **Step 4: Create `messages/it.json`** ```json { "meta": { "title": "Ombrora — Scaricatore di video" }, "home": { "heading": "Ombrora", "urlLabel": "URL del video", "format": "Formato", "quality": "Qualità", "subtitles": "Scarica sottotitoli (fr, en)", "advanced": "Opzioni avanzate (opzionale, JSON)", "submit": "Scarica", "submitting": "Invio...", "errorRateLimit": "Troppe richieste. Riprova tra un'ora.", "errorGeneric": "Errore durante l'invio." }, "status": { "heading": "Stato del download", "notFound": "Download non trovato.", "loading": "Caricamento...", "pending": "In attesa", "processing": "In corso...", "polling": "Questa pagina si aggiorna automaticamente ogni 5 secondi.", "ready": "Download pronto", "download": "Scarica", "linkExpires": "Link valido fino al", "failed": "Errore nel download.", "deleted": "Il file è scaduto ed è stato eliminato. Il download non è più disponibile.", "unknown": "Stato sconosciuto" } } ``` - [ ] **Step 5: Commit** ```bash git add messages/ git commit -m "feat: add translation messages for en, fr, es, it" ``` --- ### Task 4: Restructure app routes under [locale] + create LanguageSwitcher **Files:** - Modify: `src/app/layout.tsx` (replace with minimal root pass-through) - Create: `src/components/LanguageSwitcher.tsx` - Create: `src/app/[locale]/layout.tsx` - Create: `src/app/[locale]/page.tsx` - Create: `src/app/[locale]/status/[uuid]/page.tsx` - Delete: `src/app/page.tsx` - Delete: `src/app/status/[uuid]/page.tsx` (whole `src/app/status/` tree) **Interfaces:** - Consumes: `routing` from `@/i18n/routing` - Consumes: `getTranslations`, `getMessages` from `next-intl/server` - Consumes: `NextIntlClientProvider` from `next-intl` - Consumes: `useRouter`, `usePathname` from `@/navigation` - Consumes: message keys `meta.title`, `home.heading`, `status.heading` - [ ] **Step 1: Replace `src/app/layout.tsx` with a minimal root pass-through** The root layout must exist but should not render ``/`` — those come from `[locale]/layout.tsx` so the `lang` attribute can be set dynamically. Overwrite the file with: ```tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return children } ``` - [ ] **Step 2: Create `src/components/LanguageSwitcher.tsx`** ```tsx 'use client' import { useLocale } from 'next-intl' import { useRouter, usePathname } from '@/navigation' import { routing } from '@/i18n/routing' const FLAGS: Record = { en: '🇺🇸', fr: '🇫🇷', es: '🇪🇸', it: '🇮🇹', } export function LanguageSwitcher() { const locale = useLocale() const router = useRouter() const pathname = usePathname() return (
{routing.locales.map((loc) => ( ))}
) } ``` - [ ] **Step 3: Create `src/app/[locale]/layout.tsx`** ```tsx import type { Metadata } from 'next' import { NextIntlClientProvider } from 'next-intl' import { getTranslations, getMessages } from 'next-intl/server' import { notFound } from 'next/navigation' import { routing } from '@/i18n/routing' import { LanguageSwitcher } from '@/components/LanguageSwitcher' export async function generateMetadata({ params, }: { params: Promise<{ locale: string }> }): Promise { const { locale } = await params const t = await getTranslations({ locale, namespace: 'meta' }) return { title: t('title') } } export default async function LocaleLayout({ children, params, }: { children: React.ReactNode params: Promise<{ locale: string }> }) { const { locale } = await params if (!(routing.locales as readonly string[]).includes(locale)) { notFound() } const messages = await getMessages() return ( {children} ) } ``` - [ ] **Step 4: Create `src/app/[locale]/page.tsx`** ```tsx import { getTranslations } from 'next-intl/server' import { SubmitForm } from '@/components/SubmitForm' export default async function Home({ params, }: { params: Promise<{ locale: string }> }) { const { locale } = await params const t = await getTranslations({ locale, namespace: 'home' }) return (

{t('heading')}

) } ``` - [ ] **Step 5: Create `src/app/[locale]/status/[uuid]/page.tsx`** ```tsx import { getTranslations } from 'next-intl/server' import { StatusView } from '@/components/StatusView' export default async function StatusPage({ params, }: { params: Promise<{ locale: string; uuid: string }> }) { const { locale, uuid } = await params const t = await getTranslations({ locale, namespace: 'status' }) return (

{t('heading')}

) } ``` - [ ] **Step 6: Remove the old route files** ```bash git rm src/app/page.tsx git rm -r src/app/status ``` - [ ] **Step 7: Commit** ```bash git add src/app/layout.tsx src/app/[locale]/ src/components/LanguageSwitcher.tsx git commit -m "feat: restructure routes under [locale], add LanguageSwitcher" ``` --- ### Task 5: Update SubmitForm and StatusView with translations **Files:** - Modify: `src/components/SubmitForm.tsx` - Modify: `src/components/StatusView.tsx` **Interfaces:** - Consumes: `useTranslations` from `next-intl` (available in client components because `NextIntlClientProvider` wraps the app in `[locale]/layout.tsx`) - Consumes: `useLocale` from `next-intl` - Consumes: `useRouter` from `@/navigation` (locale-aware — `router.push('/status/uuid')` auto-prepends the current locale) - Consumes: message keys `home.*` and `status.*` defined in Task 3 - [ ] **Step 1: Replace `src/components/SubmitForm.tsx`** ```tsx 'use client' import { useState, FormEvent } from 'react' import { useTranslations } from 'next-intl' import { useRouter } from '@/navigation' const FORMATS = ['mp4', 'mp3', 'webm', 'mkv'] const QUALITIES = ['best', '1080p', '720p', '480p', '360p'] export function SubmitForm() { const t = useTranslations('home') const router = useRouter() const [url, setUrl] = useState('') const [format, setFormat] = useState('mp4') const [quality, setQuality] = useState('best') const [subtitles, setSubtitles] = useState(false) const [extraArgs, setExtraArgs] = useState('') const [error, setError] = useState(null) const [loading, setLoading] = useState(false) async function handleSubmit(e: FormEvent) { e.preventDefault() setError(null) setLoading(true) const res = await fetch('/api/downloads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url, format, quality, subtitles, extraArgs: extraArgs.trim() || null, }), }) setLoading(false) if (res.status === 429) { setError(t('errorRateLimit')) return } if (!res.ok) { const body = await res.json().catch(() => ({})) setError((body as { error?: string }).error ?? t('errorGeneric')) return } const { uuid } = (await res.json()) as { uuid: string } router.push(`/status/${uuid}`) } return (
{error &&

{error}

}
) } ``` - [ ] **Step 2: Replace `src/components/StatusView.tsx`** ```tsx 'use client' import { useEffect, useState } from 'react' import { useTranslations, useLocale } from 'next-intl' type DownloadData = { uuid: string status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED' | 'FILE_DELETED' format: string quality: string fileName: string | null fileSize: string | null errorMsg: string | null downloadToken: string | null tokenExpiresAt: string | null } export function StatusView({ uuid }: { uuid: string }) { const t = useTranslations('status') const locale = useLocale() const [data, setData] = useState(null) const [notFound, setNotFound] = useState(false) useEffect(() => { let active = true async function poll() { const res = await fetch(`/api/downloads/${uuid}`) if (!active) return if (res.status === 404) { setNotFound(true) return } const body = (await res.json()) as DownloadData setData(body) if (body.status === 'PENDING' || body.status === 'PROCESSING') { setTimeout(poll, 5_000) } } poll() return () => { active = false } }, [uuid]) if (notFound) return

{t('notFound')}

if (!data) return

{t('loading')}

if (data.status === 'PENDING' || data.status === 'PROCESSING') { return (

{data.status === 'PENDING' ? t('pending') : t('processing')}

{t('polling')}

) } if (data.status === 'DONE' && data.downloadToken) { const sizeMb = data.fileSize ? `(${(Number(data.fileSize) / 1_048_576).toFixed(1)} MB)` : '' return (

{t('ready')} {sizeMb}

{t('download')} {data.fileName} {data.tokenExpiresAt && (

{t('linkExpires')}{' '} {new Date(data.tokenExpiresAt).toLocaleString(locale)}

)}
) } if (data.status === 'FAILED') { return (

{t('failed')}

          {data.errorMsg}
        
) } if (data.status === 'FILE_DELETED') { return

{t('deleted')}

} return

{t('unknown')}: {data.status}

} ``` - [ ] **Step 3: Commit** ```bash git add src/components/SubmitForm.tsx src/components/StatusView.tsx git commit -m "feat: translate SubmitForm and StatusView with next-intl" ``` --- ### Task 6: Smoke test No automated tests exist for UI components in this project. Verify manually. - [ ] **Step 1: Start the dev server** ```bash npm run dev ``` - [ ] **Step 2: Verify locale redirect** Open `http://localhost:3000` in a browser. Confirm it redirects to `/en` (or your browser's locale if it is one of `fr`, `es`, `it`). - [ ] **Step 3: Verify flag switcher** Confirm four flag buttons appear at the top of the page: 🇺🇸 🇫🇷 🇪🇸 🇮🇹. Click each and confirm: - The URL changes to `/fr`, `/en`, `/es`, `/it` - The page text changes language - The active flag is fully opaque; others are at 40% opacity - [ ] **Step 4: Verify form submission redirect** Submit a valid YouTube URL. Confirm the browser redirects to `/[current-locale]/status/[uuid]` (not `/status/[uuid]`). - [ ] **Step 5: Verify status page** Open a status URL directly (`/fr/status/some-uuid`). Confirm the heading and all status strings are in French. - [ ] **Step 6: Verify API routes are unaffected** Confirm `http://localhost:3000/api/downloads` still returns a JSON response (not a 404 or locale redirect).