Implementation: next-intl Supported Languages: English (en), Spanish (es) Status: Fully Configured
This LMS platform supports multiple languages using next-intl, a powerful i18n library for Next.js. Users can switch between languages dynamically, and their preference is preserved across sessions.
| Code | Language | Status |
|---|---|---|
en |
English | Complete |
es |
Español | Complete |
Default Language: English (en)
├── messages/
│ ├── en.json # English translations
│ └── es.json # Spanish translations
├── components/
│ └── language-switcher.tsx # Language switcher component
├── i18n.ts # i18n configuration
└── proxy.ts # Locale detection & tenant resolution middleware
Note:
proxy.tsis the ONLY middleware file in this project. Do not create amiddleware.tsfile — it will conflict withproxy.ts.
All translation files are located in the /messages directory with the naming convention {locale}.json.
Translations are organized hierarchically by feature. The messages/en.json and messages/es.json files contain 100+ top-level namespaces. Key groups include:
{
"common": { ... }, // Common UI elements
"auth": { ... }, // Authentication
"dashboard": {
"student": { ... }, // Student dashboard
"teacher": { ... }, // Teacher dashboard
"admin": { ... } // Admin dashboard
},
"course": { ... }, // Course-related
"lesson": { ... }, // Lesson-related
"exam": { ... }, // Exam-related
"review": { ... }, // Reviews
"enrollment": { ... }, // Enrollments
"user": { ... }, // User management
"transaction": { ... }, // Transactions
"stats": { ... }, // Statistics
"actions": { ... }, // Actions/buttons
"validation": { ... }, // Form validation
"messages": { ... }, // System messages
"aristotle": { ... }, // AI tutor
"puck": { ... }, // Puck visual editor
"builder": { ... }, // Page builder
"landing": { ... }, // Landing pages
"certificates": { ... }, // Certificate system (root-level keys)
"verification": { ... }, // Public certificate verification page
"achievements": { ... }, // Gamification achievements
"levels": { ... }, // Gamification levels
"store": { ... }, // Gamification store
"billing": { ... }, // School billing
"plans": { ... }, // Platform plans
"features": { ... }, // Feature gating
"limits": { ... }, // Plan limits
"appearance": { ... }, // Appearance settings
"branding": { ... }, // Tenant branding
"theme": { ... }, // Theme settings
"onboarding": { ... }, // Onboarding flows
"invitations": { ... }, // User invitations
"tours": { ... } // Guided tours
}import { useTranslations } from 'next-intl'
export default function MyComponent() {
const t = useTranslations('dashboard.student')
return (
<div>
<h1>{t('title')}</h1>
<p>{t('welcome')}</p>
</div>
)
}'use client'
import { useTranslations } from 'next-intl'
export function MyClientComponent() {
const t = useTranslations('common')
return (
<button>{t('save')}</button>
)
}const t = useTranslations('dashboard')
// Access: dashboard.student.title
<h1>{t('student.title')}</h1>
// Or use a specific namespace
const studentT = useTranslations('dashboard.student')
<h1>{studentT('title')}</h1>{
"items": "{count, plural, =0 {No items} =1 {1 item} other {# items}}"
}t('items', { count: 0 }) // "No items"
t('items', { count: 1 }) // "1 item"
t('items', { count: 5 }) // "5 items"{
"welcome": "Welcome, {name}!"
}t('welcome', { name: 'John' }) // "Welcome, John!"The LanguageSwitcher component is available globally and can be added to any layout or page.
import { LanguageSwitcher } from '@/components/language-switcher'
export default function Layout() {
return (
<header>
<nav>
{/* Other nav items */}
<LanguageSwitcher />
</nav>
</header>
)
}- Dropdown selector with language names
- Current language highlighted
- Preserves current path when switching
- Updates URL with locale prefix (e.g.,
/en/dashboardto/es/dashboard)
The [locale] segment is always present in all routes. All app routes live under app/[locale]/.
/en/dashboard/student
/en/dashboard/teacher
/en/auth/login
/es/dashboard/student
/es/dashboard/teacher
/es/auth/login
The proxy.ts middleware handles locale detection and routing. The [locale] segment is always present in the URL for both default and non-default locales.
Edit i18n.ts:
export const locales = ['en', 'es', 'fr'] as const // Add 'fr'
export const localeNames: Record<Locale, string> = {
en: 'English',
es: 'Español',
fr: 'Français', // Add French
}Create messages/fr.json by copying messages/en.json and translating all strings.
Edit proxy.ts to include the new locale in its matcher and detection logic:
export const config = {
matcher: ['/', '/(en|es|fr)/:path*'], // Add 'fr'
}- Navigate to
/fr/dashboard - Verify all translations display correctly
- Test language switcher includes new language
loading,error,save,cancel,delete,edit,create,submitsearch,back,next,previous,continue,close
login,signup,logout,email,passwordforgotPassword,resetPassword,dontHaveAccount,alreadyHaveAccount
- Student:
title,welcome,enrolledCourses,lessonsCompleted, etc. - Teacher:
title,welcome,createCourse,totalCourses, etc. - Admin:
title,welcome,totalUsers,quickActions, etc.
title,description,lessons,exams,students,progresscomplete,completed,inProgress,notStartedpublished,draft,archived
markAsComplete,completed,duration,minutescomments,noComments,postComment
startExam,submitExam,timeRemaining,questionsmultipleChoice,trueFalse,freeTextscore,passed,failed,results
yourRating,yourReview,submitReviewnoReviews,alreadyReviewed,averageRating
profile,settings,account,name,email,rolestudent,teacher,admin,joined
preview,publish,unpublish,archive,restoreduplicate,export,import,viewAll,viewDetails
required,emailInvalid,passwordTooShort,passwordsMustMatch
success,saveSuccess,deleteSuccess,errorOccurredconfirmDelete,unsavedChanges
aristotle— AI tutor interfacepuck/builder/landing— Visual page builder and landing pagescertificates— Certificate generation and verification (root-level: getCertificate, viewCertificate, generating, etc.)verification— Public certificate verification page (verifiedCredential, certificateRevoked, issuingOrganization, verificationId, footerText, notFound.*)achievements/levels/store— Gamification systembilling/plans/features/limits— Monetization and feature gatingappearance/branding/theme— Tenant theming and customizationonboarding— Onboarding flows for new schools and usersinvitations— User invitation systemtours— Guided product tours
Do not hardcode strings:
// Wrong
<h1>Welcome to the Dashboard</h1>
// Correct
<h1>{t('dashboard.student.welcome')}</h1>Group related translations under a common namespace:
{
"course": {
"title": "Course",
"description": "Description",
"lessons": "Lessons"
}
}Use clear, semantic key names:
{
"submitReview": "Submit Review",
"btn1": "Submit Review"
}The first is clear; the second is not.
Translation strings should be plain text, not JSX:
{
"terms": "I agree to the Terms and Conditions"
}For links, use rich text or split the translation:
{
"termsPrefix": "I agree to the",
"termsLink": "Terms and Conditions"
}{
"enrolled": "Enrolled on {date}",
"progress": "{completed} of {total} lessons completed"
}t('enrolled', { date: '2024-01-31' })
t('progress', { completed: 5, total: 10 })Use consistent terminology across all translations:
- "Login" (not "Sign In" in some places and "Log In" in others)
- "Course" (not "Class" or "Program")
- "Lesson" (not "Lecture" or "Module")
- Switch language using the LanguageSwitcher
- Navigate through all pages
- Verify all text displays in correct language
- Check for missing translations (shows key instead of text)
// Test language switcher
test('should switch language', async () => {
const { getByRole } = render(<LanguageSwitcher />)
const selector = getByRole('combobox')
fireEvent.change(selector, { target: { value: 'es' } })
expect(window.location.pathname).toContain('/es')
})# Compare English and Spanish keys
diff <(jq -r 'keys' messages/en.json) <(jq -r 'keys' messages/es.json)Symptom: Page shows key instead of translated text (e.g., dashboard.student.title)
Solution:
- Check that the key exists in the translation file
- Verify the namespace is correct
- Ensure the translation file is imported correctly
Symptom: Spanish text shows when English is selected
Solution:
- Clear browser cache
- Check URL for correct locale prefix
- Verify
defaultLocaleini18n.ts
Symptom: Language resets on page refresh
Solution:
- The
proxy.tsmiddleware handles persistence via URL - Ensure all internal links use the locale-aware router
- Don't use regular
<a>tags, use Next.js<Link>
Symptom: Module not found: Can't resolve './messages/en.json'
Solution:
- Ensure
messages/directory exists - Verify all locale files are present (en.json, es.json)
- Check file names match locale codes exactly
export default function StudentDashboard() {
return (
<div>
<h1>My Learning</h1>
<p>Welcome back! Continue where you left off.</p>
<div>
<span>Enrolled Courses</span>
<span>Lessons Completed</span>
</div>
</div>
)
}import { useTranslations } from 'next-intl'
export default function StudentDashboard() {
const t = useTranslations('dashboard.student')
return (
<div>
<h1>{t('title')}</h1>
<p>{t('welcome')}</p>
<div>
<span>{t('enrolledCourses')}</span>
<span>{t('lessonsCompleted')}</span>
</div>
</div>
)
}- Each locale adds ~10-15KB to the bundle
- Translations are loaded on-demand per page
- Only the active locale is loaded
- Translation files are cached by the browser
- Changes to translations require browser cache clear in development
- Production builds have cache busting via build hashes
- French (fr)
- German (de)
- Portuguese (pt)
- Italian (it)
- Chinese (zh)
- Japanese (ja)
- RTL (Right-to-Left) support for Arabic/Hebrew
- Date/time localization (certificates use locale-aware
toLocaleDateString) - Number formatting per locale
- Currency formatting
- Timezone support
- next-intl Documentation: https://next-intl-docs.vercel.app/
- Translation Files:
/messages/ - Configuration:
/i18n.ts - Middleware:
/proxy.ts(the only middleware file — handles locale detection, tenant resolution, auth, and role routing)
For translation contributions or issues:
- Create a new translation file in
/messages/{locale}.json - Copy the structure from
en.json - Translate all strings
- Test thoroughly
- Submit a pull request
Last Updated: March 13, 2026 Version: 2.1.0