21 KiB
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:
routingexported fromsrc/i18n/routing.ts— used by middleware, layout, and LanguageSwitcher -
Produces:
useRouter,usePathname,Linklocale-aware hooks fromsrc/navigation.ts— used by SubmitForm and LanguageSwitcher -
Step 1: Install next-intl
npm install next-intl
- Step 2: Create
src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['en', 'fr', 'es', 'it'],
defaultLocale: 'en',
})
- Step 3: Create
src/i18n/request.ts
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
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
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
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:
routingfromsrc/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
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
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,statusconsumed bygetTranslations/useTranslationsin 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
{
"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
{
"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
{
"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
{
"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
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(wholesrc/app/status/tree)
Interfaces:
-
Consumes:
routingfrom@/i18n/routing -
Consumes:
getTranslations,getMessagesfromnext-intl/server -
Consumes:
NextIntlClientProviderfromnext-intl -
Consumes:
useRouter,usePathnamefrom@/navigation -
Consumes: message keys
meta.title,home.heading,status.heading -
Step 1: Replace
src/app/layout.tsxwith a minimal root pass-through
The root layout must exist but should not render <html>/<body> — those come from [locale]/layout.tsx so the lang attribute can be set dynamically. Overwrite the file with:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return children
}
- Step 2: Create
src/components/LanguageSwitcher.tsx
'use client'
import { useLocale } from 'next-intl'
import { useRouter, usePathname } from '@/navigation'
import { routing } from '@/i18n/routing'
const FLAGS: Record<string, string> = {
en: '🇺🇸',
fr: '🇫🇷',
es: '🇪🇸',
it: '🇮🇹',
}
export function LanguageSwitcher() {
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
return (
<div style={{ display: 'flex', gap: '0.5rem', padding: '0.5rem 2rem' }}>
{routing.locales.map((loc) => (
<button
key={loc}
onClick={() => router.replace(pathname, { locale: loc })}
aria-label={loc}
style={{
fontSize: '1.5rem',
background: 'none',
border: 'none',
cursor: 'pointer',
opacity: loc === locale ? 1 : 0.4,
}}
>
{FLAGS[loc]}
</button>
))}
</div>
)
}
- Step 3: Create
src/app/[locale]/layout.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<Metadata> {
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 (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
<LanguageSwitcher />
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
- Step 4: Create
src/app/[locale]/page.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 (
<main style={{ padding: '2rem' }}>
<h1>{t('heading')}</h1>
<SubmitForm />
</main>
)
}
- Step 5: Create
src/app/[locale]/status/[uuid]/page.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 (
<main style={{ padding: '2rem' }}>
<h1>{t('heading')}</h1>
<StatusView uuid={uuid} />
</main>
)
}
- Step 6: Remove the old route files
git rm src/app/page.tsx
git rm -r src/app/status
- Step 7: Commit
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:
useTranslationsfromnext-intl(available in client components becauseNextIntlClientProviderwraps the app in[locale]/layout.tsx) -
Consumes:
useLocalefromnext-intl -
Consumes:
useRouterfrom@/navigation(locale-aware —router.push('/status/uuid')auto-prepends the current locale) -
Consumes: message keys
home.*andstatus.*defined in Task 3 -
Step 1: Replace
src/components/SubmitForm.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<string | null>(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 (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 480 }}>
<label>
{t('urlLabel')}
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
placeholder="https://www.youtube.com/watch?v=..."
style={{ display: 'block', width: '100%' }}
/>
</label>
<label>
{t('format')}
<select value={format} onChange={(e) => setFormat(e.target.value)} style={{ display: 'block' }}>
{FORMATS.map((f) => <option key={f}>{f}</option>)}
</select>
</label>
<label>
{t('quality')}
<select value={quality} onChange={(e) => setQuality(e.target.value)} style={{ display: 'block' }}>
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
</select>
</label>
<label>
<input
type="checkbox"
checked={subtitles}
onChange={(e) => setSubtitles(e.target.checked)}
/>{' '}
{t('subtitles')}
</label>
<label>
{t('advanced')}
<input
type="text"
value={extraArgs}
onChange={(e) => setExtraArgs(e.target.value)}
placeholder='["--sponsorblock-remove","all"]'
style={{ display: 'block', width: '100%' }}
/>
</label>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={loading}>
{loading ? t('submitting') : t('submit')}
</button>
</form>
)
}
- Step 2: Replace
src/components/StatusView.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<DownloadData | null>(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 <p>{t('notFound')}</p>
if (!data) return <p>{t('loading')}</p>
if (data.status === 'PENDING' || data.status === 'PROCESSING') {
return (
<div>
<p>{data.status === 'PENDING' ? t('pending') : t('processing')}</p>
<p>{t('polling')}</p>
</div>
)
}
if (data.status === 'DONE' && data.downloadToken) {
const sizeMb = data.fileSize
? `(${(Number(data.fileSize) / 1_048_576).toFixed(1)} MB)`
: ''
return (
<div>
<p>{t('ready')} {sizeMb}</p>
<a
href={`/api/download/${data.downloadToken}`}
download={data.fileName ?? undefined}
>
{t('download')} {data.fileName}
</a>
{data.tokenExpiresAt && (
<p style={{ fontSize: '0.85rem', color: '#666' }}>
{t('linkExpires')}{' '}
{new Date(data.tokenExpiresAt).toLocaleString(locale)}
</p>
)}
</div>
)
}
if (data.status === 'FAILED') {
return (
<div>
<p>{t('failed')}</p>
<pre style={{ background: '#fee', padding: '0.5rem', overflowX: 'auto' }}>
{data.errorMsg}
</pre>
</div>
)
}
if (data.status === 'FILE_DELETED') {
return <p>{t('deleted')}</p>
}
return <p>{t('unknown')}: {data.status}</p>
}
- Step 3: Commit
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
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).