Managing multilingual content is essential for global reach. TinaCMS provides versatile options to facilitate this. This guide focuses on two main strategies:
With directory-based localization, your content should be structured in a directory-based manner, where the locale (e.g., en, fr) lives underneath the collection root (e.g., blog, docs). For example:
.āāā /blog/ā āāā /en/ā ā āāā hello-world.mdā āāā /fr/ā āāā hello-world.mdāāā /docs/āāā /en/ā āāā my-doc.mdāāā /fr/āāā my-doc.md
In your config.ts, you likely will have one collection that contains all your locales
export const config = {collections: [{label: 'Blog',name: 'blog',path: 'content/blog',// ...other settings},}
Whether you're using Next.js, or another framework, your routing logic should be updated to pick the correct locale based on either the URL or user setting.
// Example: Fetching the page list in NextJSconst getStaticPaths = async({ locales }) {// ...})
Using the locale, you can filter for document(s) based on the path
// /pages/post/[filename].tsx// `locale` is provided alongside `params`const getStaticProps = async({ params, locale }) {const tinaProps = await client.BlogPostQuery({// compose `relativePath` where `locale` is a sub-folder to the `post`relativePath: `${locale}/${params.filename}.mdx`,});return {props: {...tinaProps}}}
For more info on setting up the routing for NextJS-specific implementations, see our guide
With this setup, editors will browse locales for each collection via the document list.
If a user wants to create a new localized version of an existing document, they can click "duplicate document" from the document list, and prepend the desired locale in the new document's filename.
In this approach, each localized field contains nested values for multiple languages. For example, a single Markdown file might look like this:
{"title": {"en": "Hello","fr": "Bonjour"}}
You will need to modify your TinaCMS schema to include localized fields.
export const pageSchema = {label: 'Page',name: 'page',fields: [{label: 'Title',name: 'title',type: 'object',fields: [{type: 'string',name: 'en',label: 'English',},{type: 'string',name: 'fr',label: 'French',},],},// ...other fields],};
Note: If you are using markdown/mdx content, and want to use the markdown body for your content, you might prefer using the directory-based approach to localization.
In your site's components, you can then choose the correct localized field to display based on the current locale.
const PageComponent = ({ data, locale }) => {const title = data.title[locale];// ...display content};
TinaCMS will display all localized fields as children of the root-level field.
When using Directory-Based Localization, developers need to place the corresponding language MDX or JSON files in the correct location for loading multilingual routes. Developers can choose to manually translate text content and place it in the correct location, or they can use a GitHub Action to automate this process. The following is an example explaining the workflow of an effective GitHub Action:
A trigger condition must be set for this GitHub Action. Generally, triggering occurs when a PR is successfully merged. To reduce token consumption, scheduled triggering is an alternative. The example below shows a GitHub Action that activates only when a PR is merged with changes in the specified directory.
# Only triggered after a PR is successfully closed and contains changes in the specified directoryon:pull_request:types: [closed]paths:- 'content/docs/**.mdx'jobs:translate-mdx:if: github.event.pull_request.merged == true
To minimize token usage, identifying only modified files for translation is essential. Below is a simple example of listing all the modified files.
// Identify modified target files under currrent PRasync function getChangedFilesFromApi() {try {const response = await axios.get(`https://api.github.com/repos/${OWNER}/${REPO_NAME}/pulls/${PR_NUMBER}/files`,{headers: {Authorization: `token ${GITHUB_TOKEN}`,Accept: 'application/vnd.github.v3+json',},});const mdxFiles = response.data.filter((file) =>file.filename.startsWith('content/docs/') &&file.filename.endsWith('.mdx')).map((file) => file.filename);return mdxFiles;} catch (error) {console.error('Error fetching changed files from API:', error.message);return [];}}
Creating a separate branch to differentiate between the original branch and newly added translations is essential. This approach facilitates a manual review.
# Create a separate branch for translated files- name: Create translation branchif: env.HAS_CHANGED_FILES == 'true'run: |TIMESTAMP=$(date +%s)PR_NUMBER="${{ github.event.pull_request.number }}"BRANCH_NAME="translate-pr-$PR_NUMBER-$TIMESTAMP"git checkout -b $BRANCH_NAMEecho "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
External LLMs are employed for automatic translation. Translation quality and format integrity depend on the selected model and the prompt. Below is an English prompt example for translating MDX files into Chinese while preserving document interfaces and critical information.
Please translate the following Markdown content into Chinese, preserving all Markdown formatting, code blocks, and front matter metadata.Only translate the text content, do not modify code, variable names, or other technical content.Do not add ```markdown``` in the newly translated Markdown file.Please note the following special requirements:1. Do not modify URLs, next and preview fields2. Maintain the original format structure, including heading hierarchy, lists, tables, etc.3. Comments in code blocks can be translated, but the code itself and its functionality should not be changed4. Please return the translated markdown source code directly in your reply, without adding any other prompts or explanations{{content}}
Content generated by LLMs cannot be considered fully reliable. Committing all changes to a new PR for thorough verification represents a more cautious and methodical approach.
const prTitle = `Chinese translation for PR #${PR_NUMBER}`;const prBody = `This PR contains Chinese translations for the documentation files updated in PR (${SERVER_URL}/${REPO}/pull/${PR_NUMBER}).`;const response = await axios.post(`https://api.github.com/repos/${OWNER}/${REPO_NAME}/pulls`,{title: prTitle,body: prBody,head: BRANCH_NAME,base: 'main',},{headers: {Authorization: `token ${GITHUB_TOKEN}`,Accept: 'application/vnd.github.v3+json',},});
Ā© TinaCMS 2019ā2025