🎯 Introduction
Imagine this: Your content team needs to update a translation. Instead of navigating through a complex CMS, digging through JSON files, or waiting for a developer to make changes, they simply click on the text they want to edit—right there on the page—make their changes, and save. That's the power of in-context i18n editing.
In this article, we'll build a production-ready in-context internationalization system using React and Next.js. You'll learn how to create an intuitive editing experience that empowers non-technical team members while maintaining clean, maintainable code.
🤔 The Problem
Traditional i18n workflows often involve:
- Developers managing translation files
- Content teams working in separate CMS systems
- Disconnect between what users see and what gets translated
- Time-consuming back-and-forth between teams
- Risk of breaking the UI with incorrect translations
✨ The Solution
In-context editing bridges this gap by allowing translations to be edited directly where they appear, providing:
| Benefit | Impact |
|---|---|
| Visual context | Editors see exactly where translations appear |
| Immediate feedback | Changes are visible instantly |
| Reduced friction | No need to switch between systems |
| Better accuracy | Context-aware translations |
📚 What is In-Context i18n?
In-context internationalization (i18n) editing is a development pattern that allows content editors to modify translations directly within the application interface. Instead of editing JSON files or using a separate CMS, editors can:
- Click on any translatable text
- Edit it inline
- Save changes that persist to your backend
- See updates reflected immediately
Perfect For:
- 🛍️ Marketing teams updating landing page copy
- 📝 Content managers maintaining multi-language websites
- 🎨 Product teams iterating on UI text
- 🧪 QA teams fixing translation errors during testing
🏗️ Architecture Overview
Our in-context i18n system follows a clean, modular architecture:
┌─────────────────────────────────────────────────────────┐
│ React App │
│ ┌───────────────────────────────────────────────────┐ │
│ │ TranslationProvider │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ Context API │ │ │
│ │ │ ┌──────────────┐ ┌──────────────────┐ │ │ │
│ │ │ │ EditableText │ │ LanguageSwitcher │ │ │ │
│ │ │ └──────────────┘ └──────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓ ↑
Backend API
↓ ↑
Translation Database
Key Components
- TranslationProvider: Manages translation state and API communication
- EditableText: Wraps translatable content with editing capabilities
- LanguageSwitcher: Allows switching between languages
- Backend API: Handles CRUD operations for translations
Data Flow
User clicks text → EditableText enters edit mode →
User edits → Save → API call → Backend updates →
State refresh → UI updates
🚀 Implementation Steps
Let's build this step by step. We'll use Next.js 16 with the App Router and React 19.
Step 1: Setting Up the Project
First, create a new Next.js project:
npx create-next-app@latest incontext-i18n-frontend
cd incontext-i18n-frontend
npm install
Step 2: Creating the Translation Provider
The TranslationProvider is the heart of our system. It manages current language state, translation data, API communication, and edit mode.
Create i18n/TranslationProvider.js:
"use client";
import { createContext, useEffect, useState } from "react";
const TranslationContext = createContext();
export function TranslationProvider({ children }) {
const [lang, setLang] = useState("en");
const [translations, setTranslations] = useState({});
const [editMode] = useState(process.env.NODE_ENV === "development");
useEffect(() => {
fetch(`http://localhost:4000/api/translations/${lang}`)
.then((res) => res.json())
.then(setTranslations);
}, [lang]);
const t = (key) => translations[key] || key;
const updateTranslation = async (key, value) => {
await fetch(`http://localhost:4000/api/translations/${key}/${lang}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
setTranslations((prev) => ({ ...prev, [key]: value }));
};
return (
<TranslationContext.Provider
value={{ lang, setLang, t, editMode, updateTranslation, translations }}
>
{children}
</TranslationContext.Provider>
);
}
export { TranslationContext };
💡 Key Features:
- Fetches translations when language changes
- Provides
t()function for translation lookup - Handles translation updates via API
- Edit mode only enabled in development
Step 3: Creating the useTranslation Hook
A custom hook provides clean access to translation context:
"use client";
import { useContext } from "react";
import { TranslationContext } from "./TranslationProvider";
export function useTranslation() {
const context = useContext(TranslationContext);
if (!context) {
throw new Error("useTranslation must be used within a TranslationProvider");
}
return context;
}
Step 4: Building the EditableText Component
This is where the magic happens! ✨ The EditableText component displays translated text normally, switches to edit mode on click (in development), provides inline editing with keyboard shortcuts, and saves changes automatically.
Create components/EditableText.js:
"use client";
import { useState, useEffect } from "react";
import { useTranslation } from "../i18n/useTranslation";
export function EditableText({ translationKey, i18nKey, className = "" }) {
const { t, updateTranslation, editMode, lang, translations } = useTranslation();
const key = i18nKey || translationKey;
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(t(key));
useEffect(() => {
if (!isEditing) {
const currentTranslation = translations[key] || key;
setTimeout(() => setValue(currentTranslation), 0);
}
}, [lang, translations, key, isEditing]);
const handleEdit = () => {
if (editMode) {
setValue(t(key));
setIsEditing(true);
}
};
const handleSave = () => {
updateTranslation(key, value);
setIsEditing(false);
};
const handleCancel = () => {
setValue(t(key));
setIsEditing(false);
};
if (isEditing && editMode) {
return (
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
else if (e.key === "Escape") handleCancel();
}}
autoFocus
className="glass-input"
style={{
position: "relative",
zIndex: 1000,
padding: "8px 12px",
border: "2px solid rgba(255, 255, 255, 0.5)",
borderRadius: "8px",
backgroundColor: "rgba(255, 255, 255, 0.25)",
backdropFilter: "blur(10px)",
color: "white",
outline: "none",
minWidth: "200px",
fontSize: "inherit",
fontWeight: "inherit",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
}}
/>
);
}
return (
<span
className={`editable-text ${className}`}
onClick={handleEdit}
style={{ cursor: editMode ? "pointer" : "default" }}
>
{t(key)}
</span>
);
}
🎮 User Experience Features:
- Click to edit
- Enter to save
- Escape to cancel
- Auto-focus on edit mode
- Blur to save (click outside)
Step 5: Creating the Language Switcher
A simple component to switch between languages:
"use client";
import { useTranslation } from "../i18n/useTranslation";
export default function LanguageSwitcher() {
const { lang, setLang } = useTranslation();
return (
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="glass-input"
style={{
padding: "8px 16px",
fontSize: "14px",
fontWeight: "600",
borderRadius: "8px",
cursor: "pointer",
appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='white' d='M6 9L1 4h10z'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
paddingRight: "36px",
}}
>
<option value="en">English</option>
<option value="fr">French</option>
<option value="ja">Japanese</option>
</select>
);
}
Step 6: Styling with Glassmorphism
We'll use a modern glassmorphism design. Add to app/globals.css:
body {
background: linear-gradient(
135deg,
#667eea 0%,
#764ba2 25%,
#f093fb 50%,
#4facfe 75%,
#00f2fe 100%
);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
min-height: 100vh;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.glass-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px;
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.37),
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
}
.glass-input {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
color: white;
}
.glass-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
Step 7: Using EditableText in Your Components
Now you can use EditableText anywhere in your app:
import { EditableText } from "../components/EditableText";
import LanguageSwitcher from "../components/LanguageSwitcher";
export default function Home() {
return (
<main>
<h1>
<EditableText i18nKey="home.title" />
</h1>
<LanguageSwitcher />
<label>
<EditableText i18nKey="login.username.label" />
</label>
<input placeholder="Enter your username" />
<button>
<EditableText i18nKey="login.submit" />
</button>
</main>
);
}
🔍 Code Deep Dive
How Translation Lookup Works
The t() function provides a fallback mechanism:
const t = (key) => translations[key] || key;
- ✅ If translation exists: returns translated value
- ⚠️ If missing: returns the key itself (prevents blank text)
- 🚀 In production: shows the key as fallback
- 🛠️ In development: you can edit to add the translation
State Management Strategy
We use React Context for global state because:
- Simple: No need for Redux or complex state management
- Lightweight: Perfect for translation data
- Reactive: Components update automatically when language changes
- Scalable: Easy to add features like caching or optimistic updates
Edit Mode Logic
Edit mode is controlled by environment:
const [editMode] = useState(process.env.NODE_ENV === "development");
Why this approach?
- ✅ Production safety: No accidental edits in live sites
- 🛠️ Development convenience: Immediate editing during development
- 🔧 Alternative: Use feature flags or user roles for production editing
🎨 Key Features
1. Inline Editing
Click any translatable text to edit it directly. No modals, no separate pages—just click and type.
2. Keyboard Shortcuts
- Enter: Save changes
- Escape: Cancel editing
- Blur: Auto-save on click outside
3. Language Switching
Switch languages instantly with the dropdown. All translations update automatically.
4. Visual Feedback
- Cursor changes to pointer in edit mode
- Input field appears with glassmorphism styling
- Smooth transitions between states
5. Development-Only Editing
Edit mode is automatically disabled in production, preventing accidental changes.
6. Fallback Handling
Missing translations show the key instead of blank text, making it easy to identify what needs translation.
📖 Best Practices
1. Organize Translation Keys
Use a hierarchical naming convention:
// ✅ Good
"home.title"
"login.username.label"
"checkout.payment.methods.creditCard"
// ❌ Avoid
"title1"
"label2"
"text3"
2. Handle Loading States
Add loading indicators while fetching translations:
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/translations/${lang}`)
.then((res) => res.json())
.then((data) => {
setTranslations(data);
setLoading(false);
});
}, [lang]);
3. Error Handling
Implement proper error handling:
const updateTranslation = async (key, value) => {
try {
const response = await fetch(`/api/translations/${key}/${lang}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
if (!response.ok) {
throw new Error("Failed to update translation");
}
setTranslations((prev) => ({ ...prev, [key]: value }));
} catch (error) {
console.error("Translation update failed:", error);
// Show error toast or revert changes
}
};
4. Cache Translations
Store translations in localStorage for offline support:
useEffect(() => {
const cached = localStorage.getItem(`translations-${lang}`);
if (cached) {
setTranslations(JSON.parse(cached));
}
fetch(`/api/translations/${lang}`)
.then((res) => res.json())
.then((data) => {
setTranslations(data);
localStorage.setItem(`translations-${lang}`, JSON.stringify(data));
});
}, [lang]);
🖥️ Backend API Requirements
Your backend should provide these endpoints:
GET /api/translations/:lang
Returns all translations for a language:
{
"home.title": "Welcome",
"login.username.label": "Username",
"login.submit": "Sign In"
}
PUT /api/translations/:key/:lang
Updates a specific translation:
Request:
{
"value": "New Translation Text"
}
Response:
{
"success": true,
"key": "home.title",
"lang": "en",
"value": "New Translation Text"
}
Example Backend (Node.js/Express)
const express = require("express");
const app = express();
app.use(express.json());
// In-memory store (use a database in production)
const translations = {
en: {
"home.title": "Welcome",
"login.username.label": "Username",
"login.submit": "Sign In"
},
fr: {
"home.title": "Bienvenue",
"login.username.label": "Nom d'utilisateur",
"login.submit": "Se connecter"
}
};
app.get("/api/translations/:lang", (req, res) => {
const { lang } = req.params;
res.json(translations[lang] || {});
});
app.put("/api/translations/:key/:lang", (req, res) => {
const { key, lang } = req.params;
const { value } = req.body;
if (!translations[lang]) {
translations[lang] = {};
}
translations[lang][key] = value;
res.json({ success: true, key, lang, value });
});
app.listen(4000, () => {
console.log("Translation API running on http://localhost:4000");
});
🚀 Advanced Enhancements
1. Translation History
Track changes over time to see who edited what and when.
2. Translation Status
Show which translations are missing or need review with visual indicators.
3. Bulk Editing
Allow editing multiple translations at once for faster workflows.
4. Translation Suggestions
Integrate with translation APIs for AI-powered suggestions.
🎯 Conclusion
In-context i18n editing transforms how teams manage translations. By allowing direct editing within the application, you:
- ⚡ Reduce friction between content and development teams
- 🚀 Speed up iteration on translations
- 🎯 Improve accuracy with visual context
- 💪 Empower non-technical team members
Next Steps
- Add error handling and loading states
- Implement caching for better performance
- Add user permissions for production editing
- Integrate with your CMS or translation service
- Add analytics to track translation usage
📚 Resources
Happy coding! 🚀
If you found this article helpful, consider:
- ⭐ Starring the repository
- 📝 Sharing your own implementation
- 💬 Leaving feedback or questions in the comments
- 🔄 Following for more React/Next.js tutorials
What's your approach to managing translations? Have you tried in-context editing? Share your thoughts in the comments below! 👇
Top comments (0)