Files
video-downloader/docs/superpowers/plans/2026-08-10-i18n.md
T

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: 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

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: 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

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, 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

{
  "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 (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 <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: 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

'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).