We currently use the library i18next for our projects. If you are not familiar with what translation files are, check out the i18next essentials for a quick run through of how their translation files are used in development.
Below are some of the best practices we use to keep translation files short, manageable, easy to read, and (hopefully) easy to translate.
Start with standardized key groups
Start with a standard template of four key groups:
app
- Words that are specifically about the app (or website), like the title and copyright text.common
- Things that are reused everywhere, like “Read More”, "Save", and "Cancel".glossary
- Words specific to the app that are reused consistently. For example, a site might have “Tags” or “Categories”.form
- Things that are used in forms, such as fields and validation messages.
For the rest of the site, group keys according to the domain the key belongs to, and let the reader know about what might be the purpose of the key. For example, adding “_placeholder” or “_button” to the end of your key gives the reader a clear idea of where the string is used.
home
- Keys for the homepagenews
- Keys for a news list, detail page, or navigation
Sample json file:
"app": {
"title": "Site Name",
"copyright": "Copyright {{ year }}"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"read_more": "Read More"
},
"glossary": {
"collection": "Collection",
"collection_other": "Collections"
},
"form": {
"fields": {
"zip_code": "Zip Code",
"zip_code_placeholder": "Enter your zip code"
},
"validation": {
"required": "This field is required",
}
},
"home": {
"welcome_text": "Hello, {{ name }}"
}
"app": {
"title": "Site Name",
"copyright": "Copyright {{ year }}"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"read_more": "Read More"
},
"glossary": {
"collection": "Collection",
"collection_other": "Collections"
},
"form": {
"fields": {
"zip_code": "Zip Code",
"zip_code_placeholder": "Enter your zip code"
},
"validation": {
"required": "This field is required",
}
},
"home": {
"welcome_text": "Hello, {{ name }}"
}
Naming Keys
Whenever possible, use generic, short translation keys. The shorter the key, the more places where it can be reused. While you can use nested keys, deeply nested keys are harder to manage. At Cast Iron, we try to avoid using more than three levels.
Avoid heavily nested names:
common.users.hobbies.surfing
Limit keys to two or three levels:
hobbies.surfing
common.learn_more
If the key is too abstract, translators will have trouble knowing what context the key is used in or what it should say. If the key is it too close to the actual content and the content changes, updating the key name could be difficult.
Avoid abstract names:
"home": {
"paragraph_1": "Welcome to....",
"su_lbl": "Sign Up"
}
"home": {
"paragraph_1": "Welcome to....",
"su_lbl": "Sign Up"
}
Use short, contextual names:
"home": {
"welcome_text": "Welcome to..."
"sign_up_button": "Sign Up"
}
"home": {
"welcome_text": "Welcome to..."
"sign_up_button": "Sign Up"
}
Another thing to consider is your audience when naming keys. If you know your translation file will be read by more technical folks, you might be able to get away with some abbreviations in your keys. The label a11y
below is a common technical shorthand for “accessibility”, and most developers would understand what this means. But if your translation file will be read by someone non-technical, then they might not understand what they key is used for. Lean toward not using abbreviations if possible.
"required_a11y_label": "required",
// vs
"required_hidden_label": "required"
"required_a11y_label": "required",
// vs
"required_hidden_label": "required"
Naming Style
We stick to one writing style: snake_case. You can mix styles (such as kebob-case and camelCase) and still have a working translation file, but it will be difficult to read in the long run. Snake case matches the i18next library’s format for pluralizations, so we use it to stay consistent.
"search": {
"results_one": "{{value}} Result",
"reults_other": "{{value}} Results"
}
"search": {
"results_one": "{{value}} Result",
"reults_other": "{{value}} Results"
}
Interpolated Values & Nested Translations
Some strings include variables in the translated text, such as {{year}}
, or {{name}}
. The name of these variables should follow the same conventions as keys - make sure it describes what value is getting added to the text. For example, it’s fairly clear that Copyright {{year}}
will display as “Copyright 2025”.
"app": {
"title": "My App",
"copyright": "Copyright {{ year }}"
}
"app": {
"title": "My App",
"copyright": "Copyright {{ year }}"
}
It can be tempting to nest translation keys, but it could cause more problems in the long run. Not all languages share the same semantics - order and pluralization might be completely different in another language. Writing out the entire string makes it clear to readers what exactly needs to be translated.
"glossary": {
"post": "Post"
},
"news": {
// Avoid nesting keys
"next_label": "Next $t(glossary.post)"
// Easier for translators to interpret
"next_label": "Next Post"
}
"glossary": {
"post": "Post"
},
"news": {
// Avoid nesting keys
"next_label": "Next $t(glossary.post)"
// Easier for translators to interpret
"next_label": "Next Post"
}
Separate message bundles
If the app has both an admin backend and public facing frontend, we create separate message bundles for each area. We want to avoid storing these two groups of text in the same file, since they are aimed at different audiences and have different localization requirements.
Occasionally the app is so large that we need to separate translations into separate, smaller files. This happened with one of our larger apps, Manifold. The files were broken up using the same naming conventions as keys - each file is grouped by the domain it belongs to.
DRYing out Translations
While we always want to keep our code DRY (Don’t Repeat Yourself), this can be the hardest part to consider when creating and editing your keys. Sometimes repetition is a good thing, as a label’s value might not be consistent throughout the app. Take the example below:
"glossary": {
"news": "News"
},
"nav": {
"news_link": "News & Media"
},
"news": {
"header": "News & Media"
}
"glossary": {
"news": "News"
},
"nav": {
"news_link": "News & Media"
},
"news": {
"header": "News & Media"
}
We don’t want to update the glossary term “News” to “News & Media” and accidentally change the label across the entire site. We also don’t want to use interpolation and slot the glossary label into the navigation link ($t(’glossary.news’) & Media
), making the label that much harder to read and translate.
But what about news.header
? While it shares the same label as nav.news_link
, using the navigation key for the page header isn't ideal. The key name should reflect where the text appears on the page. Additionally, if the header text needs to change in the future while the navigation stays the same, having separate keys makes updates easier.
Before adding a new key, check whether an existing key could be reused, but consider carefully if a new, context-specific key would be more appropriate. As you’re removing code, verify if the key is used anywhere else. If it isn’t, it’s probably a good time to remove the key all together.
Conclusion
Translating a site or app can seem daunting, but it doesn't have to be. Keep these best practices in mind as you build your project to avoid future headaches. Using small files, standardized grouping, short contextual keys, and maintaining a DRY list will benefit both developers and translators alike.