plans
2026-05-07
2026 05 07 Next Intl to Paraglide Migration

Next-intl to Paraglide Migration 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: Migrate from next-intl to paraglide-js with compile-time TypeScript safety and domain-based file splitting.

Architecture: Paraglide CLI compiles JSON message files to typed JavaScript functions. Middleware handles locale detection via URL prefix (/en/, /vi/). Next.js [locale] segment receives delocalized URLs. Dashboard routes use cookie-based locale detection.

Tech Stack: paraglide-js, Next.js 16 App Router, TypeScript


Current State

apps/frontend/
├── messages/
│   ├── en/           # Split JSON files (admin, booking, common, etc.)
│   └── vi/
├── routing.ts        # next-intl routing config
├── i18n/
│   └── request.ts    # next-intl server config
└── app/[locale]/    # Next.js locale-segmented routes

Translation format: Nested JSON with dot-notation keys (e.g., admin.dashboard.status)

Route structure: app/[locale]/ with URL prefixes (/en/, /vi/)

Dashboard: Under app/[locale]/dashboard/ (locale-prefixed)


File Structure After Migration

apps/frontend/
├── project.inlang/
│   └── settings.json    # Paraglide config
├── messages/
│   ├── en.json         # Flat keys: admin_dashboard_status_cancelled
│   └── vi.json
├── src/paraglide/      # Generated (gitignored)
│   ├── messages.js      # Typed message functions: m.admin_dashboard_status_cancelled()
│   ├── runtime.js      # getLocale(), setLocale(), localizeHref()
│   └── server.js       # paraglideMiddleware()
├── middleware.ts        # Locale detection
└── app/[locale]/       # Unchanged

Task 1: Install Paraglide Dependencies

Files:

  • Modify: apps/frontend/package.json

  • Step 1: Add paraglide packages

Run:

cd apps/frontend && npm install @inlang/paraglide-js @inlang/plugin-message-format
  • Step 2: Verify installation

Run:

npx paraglide-js --version

Expected: 2.x.x or similar version number

  • Step 3: Commit
git add package.json pnpm-lock.yaml
git commit -m "feat(i18n): add paraglide-js dependencies"

Task 2: Create Paraglide Project Configuration

Files:

  • Create: apps/frontend/project.inlang/settings.json

  • Step 1: Create project.inlang/settings.json

{
  "$schema": "https://inlang.com/schema/project-settings",
  "baseLocale": "en",
  "locales": ["en", "vi"],
  "modules": [
    "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"
  ],
  "plugin.inlang.messageFormat": {
    "pathPattern": "./messages/{languageTag}.json"
  }
}
  • Step 2: Verify directory structure

Run:

ls messages/en/*.json | head -5
ls messages/vi/*.json | head -5

Expected: Shows all domain files (admin.json, booking.json, common.json, etc.)

  • Step 3: Add .gitignore entry

Modify apps/frontend/.gitignore to add:

# Paraglide generated
src/paraglide/
  • Step 4: Commit
git add project.inlang/ .gitignore
git commit -m "feat(i18n): add paraglide project config"

Task 3: Flatten Message Keys

Files:

  • Create: scripts/flatten-messages.ts
  • Create: messages/en.json (consolidated)
  • Create: messages/vi.json (consolidated)

[!IMPORTANT] Paraglide works with flat keys. Current messages use nested JSON (e.g., admin.dashboard.status). This script flattens them.

  • Step 1: Create flatten script

Create apps/frontend/scripts/flatten-messages.ts:

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
 
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.join(__dirname, "..");
 
interface JsonValue {
  [key: string]: JsonValue | string;
}
 
function flatten(obj: JsonValue, prefix = ""): Record<string, string> {
  const result: Record<string, string> = {};
 
  for (const [key, value] of Object.entries(obj)) {
    const newKey = prefix ? `${prefix}_${key}` : key;
 
    if (typeof value === "string") {
      result[newKey] = value;
    } else if (typeof value === "object" && value !== null) {
      Object.assign(result, flatten(value as JsonValue, newKey));
    }
  }
 
  return result;
}
 
function mergeDomainFiles(domainPath: string): Record<string, string> {
  const result: Record<string, string> = {};
 
  if (!fs.existsSync(domainPath)) return result;
 
  const files = fs.readdirSync(domainPath).filter((f) => f.endsWith(".json"));
 
  for (const file of files) {
    const domain = file.replace(".json", "");
    const content = JSON.parse(
      fs.readFileSync(path.join(domainPath, file), "utf-8"),
    );
    const flattened = flatten(content, domain);
    Object.assign(result, flattened);
  }
 
  return result;
}
 
// Generate flattened messages for each locale
const enPath = path.join(projectRoot, "messages/en");
const viPath = path.join(projectRoot, "messages/vi");
 
const enMessages = mergeDomainFiles(enPath);
const viMessages = mergeDomainFiles(viPath);
 
// Write consolidated JSON files
fs.writeFileSync(
  path.join(projectRoot, "messages/en.json"),
  JSON.stringify(enMessages, null, 2),
);
 
fs.writeFileSync(
  path.join(projectRoot, "messages/vi.json"),
  JSON.stringify(viMessages, null, 2),
);
 
console.log(`Flattened messages:`);
console.log(`  en.json: ${Object.keys(enMessages).length} keys`);
console.log(`  vi.json: ${Object.keys(viMessages).length} keys`);
  • Step 2: Run flatten script

Run:

cd apps/frontend && npx tsx scripts/flatten-messages.ts

Expected output:

Flattened messages:
  en.json: XXX keys
  vi.json: XXX keys
  • Step 3: Verify flattened files

Run:

head -20 messages/en.json

Expected: Flat keys like admin_dashboard_status_cancelled, admin_analytics_revenue_by_show

  • Step 4: Commit
git add messages/en.json messages/vi.json scripts/flatten-messages.ts
git commit -m "feat(i18n): flatten message keys for paraglide"

Task 4: Configure TypeScript for Build-Time Safety

Files:

  • Modify: apps/frontend/tsconfig.json

  • Step 1: Update tsconfig.json

Modify apps/frontend/tsconfig.json to add allowJs: true:

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "~/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
  • Step 2: Test TypeScript config

Run:

cd apps/frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20

Expected: No errors related to allowJs

  • Step 3: Commit
git add tsconfig.json
git commit -m "feat(i18n): enable allowJs for paraglide type checking"

Task 5: Set Up Paraglide Compilation Scripts

Files:

  • Modify: apps/frontend/package.json

  • Step 1: Add paraglide scripts to package.json

Modify apps/frontend/package.json scripts section:

{
  "scripts": {
    "dev": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && next dev",
    "build": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && next build",
    "paraglide:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide",
    "paraglide:watch": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide --watch",
    "typecheck": "tsc --noEmit"
  }
}
  • Step 2: Run initial compilation

Run:

cd apps/frontend && npm run paraglide:compile 2>&1 | head -30

Expected output:

âś” compiled 1 language
âś” wrote 1 module
âś” Paraglide setup complete
  • Step 3: Verify generated files

Run:

ls src/paraglide/

Expected:

messages/
runtime.js
server.js
.gitignore
README.md
  • Step 4: Verify message structure

Run:

head -30 src/paraglide/messages.js

Expected: Exported m object with typed message functions

  • Step 5: Commit
git add package.json
git commit -m "feat(i18n): add paraglide compile scripts"

Task 6: Create Paraglide Middleware

Files:

  • Create: apps/frontend/middleware.ts

  • Step 1: Create middleware.ts

Create apps/frontend/middleware.ts:

import { paraglideMiddleware } from "./src/paraglide/server.js";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export async function middleware(request: NextRequest) {
  return paraglideMiddleware(
    request,
    async ({ request: _request, locale }) => {
      // Continue to Next.js with locale set
      const response = NextResponse.next();
      response.headers.set("x-paraglide-locale", locale);
      return response;
    },
    {
      // URL-based locale detection for public routes
      // Dashboard uses cookie-based locale (see routeStrategies)
    },
  );
}
 
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};
  • Step 2: Commit
git add middleware.ts
git commit -m "feat(i18n): add paraglide middleware"

Task 7: Update Locale Layout for Paraglide

Files:

  • Modify: apps/frontend/app/[locale]/layout.tsx

  • Step 1: Read current layout

Run:

cat apps/frontend/app/\[locale\]/layout.tsx
  • Step 2: Update to use paraglide runtime

Modify apps/frontend/app/[locale]/layout.tsx:

// SoC: Root locale layout - locale setup via paraglide
 
import { getLocale, setLocale } from "~/src/paraglide/runtime";
import { notFound } from "next/navigation";
 
function isValidLocale(locale: string): locale is "en" | "vi" {
  return locale === "en" || locale === "vi";
}
 
export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
 
  if (!isValidLocale(locale)) {
    notFound();
  }
 
  // Set locale for server components
  setLocale(locale);
 
  return <main>{children}</main>;
}
  • Step 3: Commit
git add app/\[locale\]/layout.tsx
git commit -m "feat(i18n): update layout to use paraglide runtime"

Task 8: Create Message Migration Codemod

Files:

  • Create: scripts/migrate-messages.ts
  • Modify: apps/frontend/components/**/*.tsx (migration)

[!IMPORTANT] next-intl uses t("admin.dashboard.status") pattern. Paraglide uses m.admin_dashboard_status() pattern.

  • Step 1: Create migration script

Create apps/frontend/scripts/migrate-messages.ts:

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
 
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.join(__dirname, "..");
 
function toSnakeCase(str: string): string {
  return str.replace(/\./g, "_").replace(/-/g, "_");
}
 
function migrateFile(content: string): string {
  // Replace t("key") with m.key_ified()
  // Pattern: t("admin.dashboard.status")
  let result = content.replace(
    /t\s*\(\s*["']([^"']+)["']\s*\)/g,
    (_match, key) => {
      const fnName = toSnakeCase(key);
      return `m.${fnName}()`;
    },
  );
 
  // Replace t("key", { values }) with m.key_ified({ values })
  result = result.replace(
    /t\s*\(\s*["']([^"']+)["']\s*,\s*(\{[^}]+\})\s*\)/g,
    (_match, key, values) => {
      const fnName = toSnakeCase(key);
      return `m.${fnName}(${values})`;
    },
  );
 
  return result;
}
 
function processDirectory(dir: string): string[] {
  const modified: string[] = [];
 
  if (!fs.existsSync(dir)) return modified;
 
  const entries = fs.readdirSync(dir, { withFileTypes: true });
 
  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
 
    if (entry.isDirectory()) {
      modified.push(...processDirectory(fullPath));
    } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
      let content = fs.readFileSync(fullPath, "utf-8");
 
      // Skip if no next-intl imports
      if (!content.includes("next-intl") && !content.includes('t("')) continue;
 
      const newContent = migrateFile(content);
 
      if (content !== newContent) {
        fs.writeFileSync(fullPath, newContent);
        modified.push(fullPath);
      }
    }
  }
 
  return modified;
}
 
const componentsDir = path.join(projectRoot, "components");
const appDir = path.join(projectRoot, "app");
 
const modified = [
  ...processDirectory(componentsDir),
  ...processDirectory(appDir),
];
 
console.log(`Modified ${modified.length} files:`);
modified.forEach((f) => console.log(`  ${path.relative(projectRoot, f)}`));
  • Step 2: Run migration script (dry run first)

Run:

cd apps/frontend && npx tsx scripts/migrate-messages.ts 2>&1 | head -30

Expected: List of files that would be modified

  • Step 3: Review changes

Check a sample file before committing:

git diff components/some-modified-file.tsx | head -50
  • Step 4: Commit migration
git add -A
git commit -m "feat(i18n): migrate next-intl t() calls to paraglide m.*() calls"

Task 9: Remove next-intl Artifacts

Files:

  • Remove: apps/frontend/routing.ts (next-intl routing)

  • Remove: apps/frontend/i18n/request.ts (next-intl config)

  • Remove: apps/frontend/messages/en/ (old split format)

  • Remove: apps/frontend/messages/vi/ (old split format)

  • Step 1: Verify paraglide compilation works

Run:

cd apps/frontend && npm run build 2>&1 | tail -40
  • Step 2: Remove next-intl files

Run:

rm apps/frontend/routing.ts
rm apps/frontend/i18n/request.ts
rm -rf apps/frontend/messages/en
rm -rf apps/frontend/messages/vi
  • Step 3: Update package.json to remove next-intl

Modify apps/frontend/package.json:

npm uninstall next-intl
  • Step 4: Verify build still works

Run:

cd apps/frontend && npm run build 2>&1 | tail -40

Expected: Successful build with paraglide messages

  • Step 5: Commit cleanup
git add -A
git commit -m "feat(i18n): remove next-intl artifacts"

Task 10: Verify Build-Time Type Safety

Files:

  • Create: test-type-safety.ts (temporary)

  • Step 1: Create test file to verify type safety

Create apps/frontend/test-type-safety.ts:

import { m } from "./src/paraglide/messages";
 
// This should cause TypeScript error - key doesn't exist
const wrong: string = m.nonexistent_key();
 
// This should work
const correct: string = m.admin_dashboard_title();
  • Step 2: Run TypeScript check

Run:

cd apps/frontend && npx tsc --noEmit test-type-safety.ts 2>&1

Expected: TypeScript error about nonexistent_key not existing

  • Step 3: Remove test file
rm apps/frontend/test-type-safety.ts
  • Step 4: Commit verification
git commit -m "test(i18n): verify build-time type safety works"

Task 11: Final Integration Test

  • Step 1: Full clean build

Run:

cd apps/frontend && rm -rf .next && npm run build 2>&1 | tail -50

Expected: Successful build with all routes

  • Step 2: Verify translation linting passes

Run:

cd apps/frontend && npm run lint:i18n 2>&1
  • Step 3: Test dev server

Run:

cd apps/frontend && timeout 10 npm run dev 2>&1 | head -20

Expected: Dev server starts without errors

  • Step 4: Final commit
git add -A
git commit -m "feat(i18n): complete paraglide migration"

Verification Commands

# Verify TypeScript catches missing keys
npx tsc --noEmit
 
# Verify build works
npm run build
 
# Verify message count
wc -l messages/en.json messages/vi.json
 
# Check generated message functions
head -50 src/paraglide/messages.js

Rollback Plan

If issues arise:

# Revert to previous commit
git checkout <previous-commit>
 
# Reinstall dependencies
npm install
 
# Verify next-intl works
npm run build

Files to restore:

  • routing.ts
  • i18n/request.ts
  • messages/en/ (directory)
  • messages/vi/ (directory)
  • package.json (next-intl dependency)

Architecture Summary

RequirementImplementation
Compile-time type safetyallowJs: true + JSDoc types from paraglide
File splittingSingle messages/{locale}.json with domain-key prefixes
URL locale detectionparaglide middleware + URL strategy
Dashboard localeCookie-based via middleware
Next.js [locale] segmentWorks - middleware delocalizes URLs before Next.js