Translate Sanity.io content with Phrase, directly from the Sanity studio.
- ⚡ Real-time previews: translations are kept in-sync to allow linguists and translators to see preview changes in real-time.
- ∆ Smart re-translations: the plugin diffs what content has changed since the last translation, and only sends those changes to Phrase.
- 🔗 Automatic references translation: when issuing translations, editors can choose to also translate documents referenced by the current, and the plugin will automatically link them by target language.
- 🌐 Flexible schemas: no matter the structure, this plugin will adapt to it and ensure the final translated content is according to your Sanity schemas.
- ✅ Keep your Phrase workflows: the translation workflows your team already has in Phrase will remain the same. No need to retrain or reconfigure your operations.
https://github.com/tinloof/sanity-plugin-phrase/assets/27744332/6914fee8-c09e-4e15-9b78-7619feb6ff35
npm install sanity-plugin-phrase
# or pnpm, yarn, bun
// sanity.config.(js|ts)
import {
phrasePlugin,
documentInternationalizationAdapter,
} from 'sanity-plugin-phrase'
const PHRASE_CONFIG = definePhraseOptions({
/**
* The i18n adapter to use for this plugin.
* It'll be responsible for fetching and modifying documents for each target language.
*
* See below for more information on adapters.
*/
i18nAdapter: documentInternationalizationAdapter(),
/**
* Sanity schema types the plugin can translate
*/
translatableTypes: ['page', 'post', 'course', 'lesson', 'definition'],
/**
* Language code of all languages users can translate to.
* Should be the same as the one stored in your Sanity documents and used by your front-end. The plugin will automatically translate it to Phrase's format.
*/
supportedTargetLangs: ['cz', 'es', 'pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru'],
/**
* Language code of the source language that will be translated.
* Should be the same as the one stored in your Sanity documents and used by your front-end. The plugin will automatically translate it to Phrase's format.
*/
sourceLang: 'en',
/**
* As defined by your Phrase account's settings
* Either `eu` or `us`
*/
phraseRegion: 'us|eu',
/**
* The URL to your configured plugin backend API.
*
* **Note:** follow the steps for setting up the endpoint, outlined below
*/
apiEndpoint: 'https://my-site.com/api/phrase',
/**
* Used to redirect linguists from the Phrase dashboard to the front-end preview of their translations.
*/
getDocumentPreview: async (doc, sanityClient) => {
const publishedId = doc._id.replace('drafts.', '')
return `${process.env.NEXT_PUBLIC_FRONT_END_URL}/api/draft?publishedId=${publishedId}`
},
/**
* Phrase project templates your editors can use when requesting translations.
*
* **Note:** follow the steps for setting templates, outlined below
*/
phraseTemplates: [
{
templateUid: '1jYg0Pc1d8kAHUyM0tgdmt',
label: '[Sanity.io] Default template',
},
],
/**
* @optional
* In case you want to show or hide the Phrase dashboard according to user privileges.
*
* Receives a context with the current user and document and must return a boolean.
*/
isPhraseDashboardHidden: (context) =>
!(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})
export default defineConfig({
// ...
plugins: [
// ...
phrasePlugin(PHRASE_CONFIG),
],
})
This is the endpoint that the plugin will use to communicate with the Sanity Studio. It is used to authenticate to Phrase's API, receive its webhooks, and to receive user requests from the Sanity studio.
You must create a custom API endpoint in your Sanity project to handle these requests. One of the easiest ways to do this is to use serverless functions via front-end frameworks like NextJS, Remix, SvelteKit or Nuxt.
You can access configure the handler with a Request -> Response pattern via import { createRequestHandler } from 'sanity-plugin-phrase/backend'
or use the internal handler directly via import { createInternalHandler } from 'sanity-plugin-phrase/backend'
. In either case, make sure you handle CORS requests correctly, in case your studio and endpoint are in different origins.
Here's an example using NextJS v14 API route that has very permissive CORS settings:
// src/pages/api/phrase.ts
// Next.js API route: https://nextjs.org/docs/pages/building-your-application/routing/api-routes
import type { NextApiRequest, NextApiResponse } from 'next'
import { PHRASE_CONFIG } from 'phraseConfig'
import { createInternalHandler } from 'sanity-plugin-phrase/backend'
import { writeToken } from '~/lib/sanity.api'
import { client } from '~/lib/sanity.client'
const phraseHandler = createInternalHandler({
phraseCredentials: {
userName: process.env.PHRASE_USER_NAME || '',
password: process.env.PHRASE_PASSWORD || '',
},
sanityClient: client.withConfig({ token: writeToken }),
pluginOptions: PHRASE_CONFIG,
})
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', '*')
if (req.method?.toUpperCase() === 'OPTIONS') {
res.status(200).json({})
return
}
if (
!req.method ||
(req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'GET')
) {
res.status(405).json({ error: 'Method not allowed' })
return
}
const phraseRes = await phraseHandler(
req.method.toUpperCase() === 'POST' ? req.body : req.query,
)
const resBody = await phraseRes.json().catch(() => {})
Array.from(phraseRes.headers.entries()).forEach((value) => {
res.setHeader(value[0], value[1])
})
res.status(phraseRes.status).json(resBody)
}
Note: the NextJS's app directory is currently not supported as it incorrectly parses the backend handler as a React client component.
Sanity has no prescriptive approach to internationalization, and there are many ways to implement it. This plugin uses an adapter pattern to allow you to configure how your content is structured and how it should be translated.
Currently, the only adapter available is documentInternationalizationAdapter
, which is the one used by Sanity's official document-internationalization plugin (version ^2.0.0
). File an issue if you need a specific adapter or refer to this repository's package/src/adapters/document-internationalization.ts
for an example on how to implement your own.
If you need to transform data before sending it to Phrase, you can use the dataTransformers
option. This is useful if you need to change the structure of the data, or if you need to exclude certain fields from being translated.
Each data transformer needs to encode
the data before sending it to Phrase; and decode
it when receiving it back, transforming it before saving to Sanity. You can stack multiple transformers and they will run sequentially.
💡 Example: modifying JSON-encoded VTT files to HTML so Phrase can better segment the content of subtitles
export const PHRASE_CONFIG = definePhraseOptions({
// ...
dataTransformers: [vttJsonTransformer],
})
const vttJsonTransformer: DataTransformer = {
encode: {
array(arr) {
if (
arr.every(
(item) =>
typeof item === 'object' &&
!!item &&
'_type' in item &&
typeof item._type === 'string' &&
item._type.startsWith('vtt.'),
)
) {
return encodeSubtitles(arr as StoredSubtitleNode[])
}
return undefined
},
},
decode: {
object(obj) {
if (!!obj && '_type' in obj && obj._type === 'encodedSubtitles') {
return decodeSubtitles(obj as EncodedSubtitles)
}
return undefined
},
},
}
// Skipping implementation
// Refer to /demo-nextjs/src/utils/vttJsonTransformer.ts for the full source code
declare function decodeSubtitles(
encoded: EncodedSubtitles,
): StoredSubtitleNode[]
declare function encodeSubtitles(nodes: StoredSubtitleNode[]): EncodedSubtitles
At the moment, the plugin offers no way to test these transformers in isolation, so development can be tricky. We suggest saving real target documents from your Sanity dataset to JSON and using them as test data for each of your encode/decode functions.
You can limit which editors can access the Phrase dashboard by implementing the isPhraseDashboardHidden
option. This function is equivalent to the one you’d pass to the hidden
property of a field in Sanity. It receives a context with the current user and document and must return a boolean.
For example, I could limit access to the Phrase dashboard to users with the admin
role:
const PHRASE_CONFIG = definePhraseOptions({
// ...
isPhraseDashboardHidden: (context) => {
const isAdmin = (context.currentUser.roles || []).some(
(r) => r.name === 'admin',
)
// Hide if not an admin
return !isAdmin
},
})
Go into your Phrase's webhook settings and create a new webhook with the following settings:
- URL: the URL to your plugin's API endpoint, as configured in the
apiEndpoint
option. - Events:
- Jobs
- Job deleted
- Job assigned
- Job due date changed
- Job target updated
- Projects
- Project deleted
- Project due date changed
- Other
- Pre-translation finished
- Jobs
This will ensure that the plugin is notified of any changes to your Phrase projects, and can keep the Sanity data in sync.
Configure your Phrase project template(s) with the properties you need for your workflows and team requirements. You can offer users one or more templates to choose from when ordering a new translation.
For the plugin, the most important settings to get right are:
JSON file import
- Exclude specific keys (use regexp):
(^|.*\/)(_createdAt|_id|_rev|_type|_updatedAt|_ref|_key|_sanityRev|_sanityContext|_strengthenOnPublish|phraseMetadata|_spanMeta|_blockMeta|_diff|marks|YOUR_IGNORED_KEYS_HERE|(_createdAt|_id|_rev|_type|_updatedAt|_ref|_key|_sanityRev|_sanityContext|_strengthenOnPublish|phraseMetadata|_spanMeta|_blockMeta|_diff|marks|YOUR_IGNORED_KEYS_HERE)/.*)
- You must exclude localization-specific data, like the
language
of a given document if you're using@sanity/document-internationalization
- Include any project-specific keys that you don't want to be translated, such as a
slug
for content using the same path across all languages. ReplaceYOUR_IGNORED_KEYS_HERE
with a pipe-separated list of keys to ignore. - Note: the expression above includes duplicated keys on purpose to ensure they get ignored by Phrase's RegEx parser. Make sure you duplicate them correctly.
- You must exclude localization-specific data, like the
- Context note:
/_sanityContext
Source language
Currently, this plugin operates from the assumption of having a single source language. The project template(s) must have the same source as the one configured in the plugin's sourceLanguage
.
Target languages
Make sure the languages you choose in Phrase are in sync to what you have in the plugin's configuration.
This plugin creates Phrase Translation Documents (PTDs) that exist in parallel to the target languages' documents. This allows editors to preview the translations in real-time, even before merging the translation's content with the final target.
In order to preview these translations, you must implement a preview handler in your front-end. This handler will receive the PTD's ID and must return the preview URL for that document. If you're doing previews using the document's slug or relative path (ex: /LANG/blog/my-post
), make sure you fetch content from the PTD document, and not the target language's document:
// Simplified pseudo-code for loading data in the front-end
const id = searchParams?.id // /page-path?id=DOC_ID
const slug = pathParams.slug // /page-path/[...SLUG]
// ❌ Before
const { data } = await sanityClient.fetch(`*[slug.current == $slug]`, { slug })
// ✅ After - ensures the exact desired document is fetched if there's a previewId
const { data } = await sanityClient.fetch(
id ? `*[_id == $documentId]` : `*[slug.current == $slug]`,
{ id, slug },
)
Finally, in order to allow Phrase linguists from refreshing content in the front-end, you'll need to expose a button for them to request fresh data from Phrase. This can be done by adding a button to your front-end that calls the refresh
action of your plugin's API endpoint. Here's an example with React:
import { MouseEvent, useState } from 'react'
import { requestPTDRefresh, isPtdId } from 'sanity-plugin-phrase/utils'
function RefreshPTDButton({ previewedDocId }: { previewedDocId: string }) {
const [state, setState] = useState<'idle' | 'refreshing'>('idle')
if (!previewedDocId || !isPtdId(previewedDocId)) {
return null
}
async function handleRefresh(e: MouseEvent) {
e.preventDefault()
setState('refreshing')
const result = await requestPTDRefresh({
ptdId: previewedDocId,
apiEndpoint,
})
if (result.success === false) {
// Notify users of errors as you see fit
}
setState('idle')
}
return (
<button onClick={handleRefresh} disabled={state === 'refreshing'}>
Refresh Phrase data
</button>
)
}
The above is needed because Phrase's API does not notify webhooks on every translation segment change, only when jobs' statuses change. As a result, we need to manually ask for fresh data when in the middle of a job.
- When a translation is issued, the plugin will:
- Check if there's no conflicting translation for the same document/subset and target language
- Create a Translation Metadata Document (TMD) to keep track of important data on the translation, locking the translation from being issued again
- (if a new Phrase project) create the project in Phrase
- Process the document's content, including user-defined data transformers
- Create Phrase jobs with this formatted JSON content
- Create Phrase Translation Documents (PTDs) to receive each target language's content from Phrase
- Listen to Phrase webhooks for changes in the jobs
- In the webhook handler, the plugin will fetch the job's content and update the PTD with the new content.
- Users have the possibility to merge a PTD with the target language document to get ongoing translations live before all related jobs are done.
- When all jobs for a translation are completed (including all configured workflow steps), the plugin will mark the translation as completed (via its TMD)
- When the translation is complete, users can commit it, which will:
- Mark the TMD as committed
- Merge PTDs into corresponding target languages
- Delete PTDs
- By the end, the target language document will have the final translated content and the source document will show as having a fresh translation for that language(s)
- To avoid re-translating content that hasn't changed in future translations, only the changes between the current version and the last committed version are sent to Phrase.
Phrase Translation Documents (PTDs) are copies of source and target documents, and are stored in the same dataset. This means that if you're querying documents from Sanity, you'll get both the source and target documents, as well as their PTDs. This can lead to duplication or incorrect data in the front-end. To ensure this doesn't happen, make sure you exclude PTDs from your queries:
import { NOT_PTD } from 'sanity-plugin-phrase/utils'
// ❌ Before - can return the PTD instead of the desired document
const lessonQuery = `*[_type == 'lesson' && slug.current == $slug && publishedAt < now() && language == $language]`
// ✅ After - excludes PTDs
const lessonQuery = `*[
// ...same filters
&& ${NOT_PTD}
]`
// NOT_PTD: `phraseMetadata._type != "phrase.ptd.meta"`
If the structure between languages is different, the plugin may not be able to apply the changes between translations correctly. For example:
- A source document was translated to Tagalog, and the translation was merged successfully
- Later, editors changed one of its paragraph's content
- But that same paragraph was deleted from the Tagalog version
- In a new translation, the paragraph will be translated in Phrase, but the plugin will not be able to apply the change as it won't find where to place it.
This applies mostly to deleted content. Editors will be able to add language-specific blocks/paragraphs and edit content in place without issues, but it's important to keep in mind this plugin is intended for re-using content across translations.
They'll be saved to the source document as usual, but the plugin will not update the source content in Phrase. This is to avoid overwriting translations that are in progress.
Instead, users will need to finish ongoing translations before issuing a new one to capture these changes. As such, it's generally advised to only issue translations when the content is reviewed and final.
This plugin assumes that the target languages of a Phrase project will not change. If they do, the plugin will not be able to keep the translations in sync.
This plugin will automatically translate referenced documents when issuing a translation. However, it will only do so up to maxReferencesDepth
levels deep. This is to avoid infinite loops and to keep execution times low.
By default, maxReferencesDepth
is set to 3
. You can change this by passing a different value to the maxReferencesDepth
option in the definePhraseOptions
configuration.
To prevent editors from accidentally sending unfinished content to Phrase, drafts are not translatable by default. That includes blocking translation of documents with direct or indirect references to drafts (as defined by maxReferencesDepth
above).
You can overwrite this by passing translateDrafts: true
to the definePhraseOptions
configuration.
To avoid confusion, it's recommended to hide the "Translations" button from Sanity's document-internationalization plugin in PTDs.
This can be done by adding a custom languageFilter
to the document-internationalization plugin:
const studioConfig = defineConfig({
// ...
document: {
unstable_languageFilter: (prev, ctx) => {
const { schemaType, documentId } = ctx
return TRANSLATABLE_SCHEMAS.includes(schemaType as any) &&
documentId &&
!isPtdId(documentId)
? [
...prev,
(props) =>
DocumentInternationalizationMenu({ ...props, documentId }),
]
: prev
},
},
})
This repository uses:
- pnpm v9 to manage dependencies across the package and demos without duplication
- Turborepo to orchestrate build & dev commands
- Changesets to manage releases and versioning
- ESLint and prettier for linting & formatting
-
Bun for running tests found in
package/tests
To start developing, run:
yarn install # using Yarn v4
yarn dev # starts the demo app and plugin watcher
# Open localhost:3000/studio
You'll need to create `.env`` files and include the right environment variables to make it work:
- Duplicate
demo-nextjs/.env.example
intodemo-nextjs/.env
and populate its values -
package/.env.example
intopackage/.env
This plugin was built by Tinloof for Phrase, with close touch and lots of feedback from Braze