Translations
How text is stored, how the locale is chosen and how to add a language.
Shotstash ships in English. Every piece of text a user can see lives in messages/<locale>.json; the code only holds keys. English (messages/en.json) is the source that every other locale file mirrors. The app uses next-intl without locale routing: there are no locale prefixes in URLs.
How the locale is chosen
- The signed-in user's language (
users.locale, set in Profile settings). The browser copies it into theshotstash_localecookie so server-rendered pages match. - The
shotstash_localecookie (server components, and anonymous visitors of share pages). - The instance default,
SHOTSTASH_DEFAULT_LOCALEin.env. - English.
An unknown or unsupported value at any step is skipped. Emails follow the recipient's users.locale, then SHOTSTASH_DEFAULT_LOCALE, then English. Times shown in the browser use the viewer's own time zone; server-rendered text (emails, share pages) uses SHOTSTASH_DEFAULT_TIMEZONE (UTC by default). Absolute times always carry a zone label.
Add a locale
- Copy
messages/en.jsontomessages/<locale>.json(for examplemessages/pt-BR.json) and translate the values. Keep every key and every ICU placeholder ({count},{name},{productName}); translate the words inside plural branches ({count, plural, one {# file} other {# files}}), keeping the branch names your language needs. - Add the locale to
SUPPORTED_LOCALESand its name, written in the language itself, toLOCALE_NAMESinsrc/i18n/config.ts. - Register the file in
MESSAGESinsrc/modules/i18n/index.ts(module code such as emails uses it). - Spell the tag in canonical BCP 47 form, language lowercase and region uppercase (
en,pt-BR): the database only accepts that shape forusers.locale. Lookups ignore case (pt-brfromSHOTSTASH_DEFAULT_LOCALEor the cookie resolves topt-BR), and the canonical spelling is what gets stored. - Run the checks below and open a pull request. Missing keys fail at build time for English and show the key in other locales, so keep your file complete.
Write code that stays translatable
- Client components:
useTranslations('<namespace>'); numbers, sizes, dates and relative times throughuseFormat()fromsrc/i18n/useFormat.ts. - Server components under
src/app:getTranslations/getLocalefromnext-intl/server. - Module code (emails, notifications, background jobs):
translatorFor(locale)from@/modules/i18n, always with an explicit locale; never the request-scoped APIs. src/libcannot import the i18n layer: helpers there take the locale or the words as arguments.- Counts are ICU plurals, never string concatenation.
- Server errors: the server answers a stable
code(REST{ code, message }, GraphQLextensions.code, admin payloaderrorCode) plus an English developermessagethat is never shown. The client renderserrors.codes.<CODE>throughuseHumanizeError()and never matches message text. A new code needs a message inerrors.codes;npm testfails otherwise. - Notifications are rendered from their
typeanddata; the stored English title and body are only a fallback for old rows.
Checks
npm run lint # i18next/no-literal-string: literal JSX text and copy attributes are errors in all of src
npm run i18n:check # Indonesian leftover words anywhere in src and messages/*.json (CI runs it)
npm test # includes the error-code, notification, email and gate testsi18n:check looks for a closed list of Indonesian function words (scripts/indonesian-leftovers.json) in string literals, template text and JSX text; comments and GraphQL documents are ignored. Technical text that is never shown to users is listed with a reason in scripts/i18n-scope.json under exceptions (today: GraphQL SDL comments and GLSL shader source). An exception that no longer matches anything fails the check, and nothing user-visible may be listed there. A single line can opt out with an i18n-ignore comment, which reviewers should question. If your locale legitimately uses one of the listed words, say so in the pull request so the check can learn per-locale word lists.
Copy rules
- English, sentence case, warm and short. Talk to the user as "you". No jargon: "Search footage…", not "Search assets…". "Footage" is the word for the media people shoot and share (search, uploads, share pages); "file" is one item in a Section.
- One word per level: Project, Section, File. Other terms: Trash, Shared. Never "folder" for a Section.
- Role words in full: super admin, admin, crew, editor, viewer. Never show enum values.
- One word for dismissing a dialog: "Cancel".
- The product name is always the
{productName}placeholder (fromsrc/lib/brand.ts). - Errors say what happened and what to do next ("Couldn't load. Try again."), never raw technical text.
- Empty states invite an action. Pointer words ("click", "hover", "drag") only in text shown to hover-capable pointers; text that touch screens also see says "Tap", or uses a neutral verb such as "select" or "choose".
- Numbers and units readable: "2,600 files", "3.8 MB". Camera (EXIF) time is labelled "(camera time)" and never converted.
- One ellipsis character, "…", never three dots.
- No em dash characters.
This page is generated from docs/i18n.md in the repository; edit it there.