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 routesTranslation 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]/ # UnchangedTask 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 --versionExpected: 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 -5Expected: 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.tsExpected output:
Flattened messages:
en.json: XXX keys
vi.json: XXX keys- Step 3: Verify flattened files
Run:
head -20 messages/en.jsonExpected: 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 -20Expected: 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 -30Expected 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.jsExpected: 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 usesm.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 -30Expected: 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 -40Expected: 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>&1Expected: 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 -50Expected: 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 -20Expected: 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.jsRollback Plan
If issues arise:
# Revert to previous commit
git checkout <previous-commit>
# Reinstall dependencies
npm install
# Verify next-intl works
npm run buildFiles to restore:
routing.tsi18n/request.tsmessages/en/(directory)messages/vi/(directory)package.json(next-intl dependency)
Architecture Summary
| Requirement | Implementation |
|---|---|
| Compile-time type safety | allowJs: true + JSDoc types from paraglide |
| File splitting | Single messages/{locale}.json with domain-key prefixes |
| URL locale detection | paraglide middleware + URL strategy |
| Dashboard locale | Cookie-based via middleware |
| Next.js [locale] segment | Works - middleware delocalizes URLs before Next.js |