The plugin provides access to Phrase translated content from within the Sanity studio.
Only document-level translations are supported. Field-level translations are not supported.
Features:
-
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, the plugin adapts to it and ensures the final translated content is according to Sanity schemas.
-
Phrase workflows
The translation workflows in Phrase remain the same; retraining or reconfiguring operations is not required.
Installation is performed at the command line.
It is assumed that a website generator and Sanity Studio are already configured. If not, use one of the starter templates provided by Sanity.
Installation
Navigate to the project containing the Sanity Studio instance, and install the plugin:
npm install sanity-plugin-phrase # or pnpm, yarn, bun
Environment variables
Before configuring the plugin, the following environment variables must be set up. Create a `.env` file (or `.env.local` for Next.js) in the project root.
Examples below are given for NextJS. For other frameworks, refer to their specific documentation, and note that the `NEXT_PUBLIC_` prefix may need to be removed for public variables.
Important
Server-side variables (SANITY_WRITE_TOKEN, PHRASE_USER_NAME, PHRASE_PASSWORD) should never be exposed to the client. In Next.js, only variables prefixed with NEXT_PUBLIC_ are exposed to the browser.
# Base URL of your site (used for preview links)
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
# URL where the plugin backend handler will be located
NEXT_PUBLIC_PHRASE_PLUGIN_API_ENDPOINT="http://localhost:3000/api/phrase"
# Phrase datacenter region ('eu' or 'us')
NEXT_PUBLIC_PHRASE_REGION="eu"
# Sanity project configuration
NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"
# Sanity API token with write permissions (server-side only)
SANITY_WRITE_TOKEN=""
# Phrase credentials (server-side only)
# Note: The Phrase API expects the username portion only, NOT the full email address
PHRASE_USER_NAME="phraseUsername"
PHRASE_PASSWORD="secretPassword"
Plugin configuration
The plugin is added to sanity.config.ts with the required configuration options:
// sanity.config.ts
import { defineConfig } from 'sanity'
import {
phrasePlugin,
definePhraseOptions,
documentInternationalizationAdapter,
} from 'sanity-plugin-phrase'
const PHRASE_CONFIG = definePhraseOptions({
// Required: i18n adapter for document internationalization
i18nAdapter: documentInternationalizationAdapter(),
// Required: Document types that can be translated
translatableTypes: ['page', 'post', 'article'],
// Required: Source language (primary language)
// This must match the language defined in your Phrase project template
sourceLang: 'en',
// Required: Target languages users can translate to
// Use the same codes as your Sanity documents
// This list must match the languages defined in your Phrase project template
supportedTargetLangs: ['es', 'fr', 'de', 'pt'],
// Required: Your backend API endpoint URL
apiEndpoint: process.env.NEXT_PUBLIC_PHRASE_PLUGIN_API_ENDPOINT!,
// Required: Phrase datacenter region ('eu' or 'us')
phraseRegion: process.env.NEXT_PUBLIC_PHRASE_REGION as 'eu' | 'us',
// Required: Phrase project templates available to editors
phraseTemplates: [
{
templateUid: 'YOUR_TEMPLATE_UID_HERE',
label: 'Default Translation Template',
},
],
// Required: Generate preview URLs for linguists
getDocumentPreview: (doc, sanityClient) => {
const publishedId = doc._id.replace('drafts.', '')
return `${process.env.NEXT_PUBLIC_BASE_URL}/api/draft?id=${publishedId}`
},
// Optional settings
// Maximum depth for translating referenced documents (default: 3)
maxReferencesDepth: 3,
// Allow translation of draft documents (default: false)
translateDrafts: false,
// Custom data transformers for special content types
dataTransformers: [],
// Logging configuration for debugging
logger: {
minimumLogLevel: 'info', // 'debug' | 'info' | 'warning' | 'error' | 'fatal'
},
// Hide Phrase dashboard based on user roles
isPhraseDashboardHidden: (context) =>
!(context.currentUser.roles || []).some((r) => r.name === 'admin'),
})
export default defineConfig({
// ... your existing config
plugins: [
phrasePlugin(PHRASE_CONFIG),
// ... other plugins
],
})// 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),
],
})
Schema injection
In order to tell the plugin which document types can be translated, pass an array of document types to the injectPhraseIntoSchema function in the sanity.config.ts file:
// sanity.config.ts
import { injectPhraseIntoSchema } from 'sanity-plugin-phrase'
// List of translatable schema types. Usually exported from an index file
// wherever you have your Sanity Schema located
const TRANSLATABLE_SCHEMAS = ['page', 'post', 'course', 'lesson', 'definition']
export default defineConfig({
schema: {
types: injectPhraseIntoSchema(TRANSLATABLE_SCHEMAS, PHRASE_CONFIG),
templates: (prev) =>
prev.filter((template) => !TRANSLATABLE_SCHEMAS.includes(template.id)),
},
plugins: [
// ...
phrasePlugin({
// Your configuration options here
}),
],
})
Excluding PTDs from document lists
PTDs (Phrase Translation Documents) are temporary documents which should not appear in normal document lists inside Sanity Studio. The NOT_PTD constant provides a GROQ filter for this purpose:
// sanity.config.ts
import { NOT_PTD } from 'sanity-plugin-phrase/utils'
export default defineConfig({
// ... other config
plugins: [
structureTool({
structure: (S) =>
S.list()
.title('Content')
.items([
S.listItem()
.title('Posts')
.schemaType('post')
.child(
S.documentList()
.title('Posts')
.filter(`_type == "post" && ${NOT_PTD}`),
),
// ... other items
]),
}),
],
})
Hiding the translation menu from PTDs
When using the document-internationalization plugin, the translation menu should be hidden from PTDs. The isPtdId utility identifies PTD documents:
import { isPtdId } from 'sanity-plugin-phrase/utils'
import { DocumentInternationalizationMenu } from '@sanity/document-internationalization'
// Use the same array as translatableTypes from your PHRASE_CONFIG
const TRANSLATABLE_TYPES = ['page', 'post', 'article']
export default defineConfig({
document: {
unstable_languageFilter: (prev, ctx) => {
const { schemaType, documentId } = ctx
// Only show translation menu for real documents, not PTDs
return TRANSLATABLE_TYPES.includes(schemaType) &&
documentId &&
!isPtdId(documentId)
? [...prev, DocumentInternationalizationMenu]
: prev
},
},
})
The plugin has no configuration in the Phrase UI, but Phrase must be configured to send webhook notifications to the backend API endpoint. This enables real-time updates as translations progress.
Create a webhook
Create a webhook with these settings:
-
URL
The URL to the plugin's API endpoint, as configured in the 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
-
-
This ensures that the plugin is notified of any changes to Phrase projects and can keep Sanity data in sync.
Setup projects template(s)
Configure Phrase project template(s) with the properties required for workflows and team requirements. One or more templates can be offered to choose from when ordering a new translation. Phrase project templates must have specific JSON import settings for the plugin to function correctly. These settings control which fields are sent to translators and which are preserved as metadata.
JSON file import
Use regex to exclude specific keys:
(^|.*\/) (_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) /.*)
This expression includes duplicated keys on purpose to ensure they are ignored by Phrase's RegEx parser. Ensure they are correctly duplicated.
-
Exclude localization-specific data, like the language of a given document if using
@sanity/document-internationalization. -
Include any project-specific keys that don't require translation, such as a slug for content using the same path across all languages. Replace
YOUR_IGNORED_KEYS_HEREwith a pipe-separated list of keys to ignore. -
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
Ensure the languages chosen in Phrase are in sync to what is in the plugin's configuration.
This is the endpoint that the plugin uses to communicate with the Sanity Studio. It is used to authenticate to Phrase's API, receive webhooks and user requests from the Sanity studio.
Create a custom API endpoint in the 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.
Access configure the handler with a Request-Response pattern via import {createRequestHandler} from backend or use the internal handler directly via import {createInternalHandler} from . Ensure handle CORS requests are handled correctly the studio and endpoint have different origins.
NextJS's app directory is not currently supported as it incorrectly parses the backend handler as a React client component.
This example demonstrates creating a Route Handler at the configured apiEndpoint path at /api/phrase using the Next.js Pages Router:
// app/api/phrase/route.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
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'
export const maxDuration = 60
export const dynamic = 'force-dynamic'
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: [string, any]) => {
res.setHeader(value[0], value[1])
})
res.status(phraseRes.status).json(resBody)
}// 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)
}
i18n adapters
Sanity has no prescriptive approach to internationalization, and there are many ways to implement it. This plugin uses an adapter pattern to allow configuration based on how the content is structured and how it should be translated.
Currently, the only adapter available is , which is the one used by Sanity's official document-internationalization plugin (version ^2.0.0). File an issue if a specific adapter is required or refer to this repository's package/src/adapters/document-internationalization.ts for an example on how to implement a customized one.
Custom data transformers
If transforming data before sending it to Phrase is required, use the option. This is useful if changing the structure of the data, or if excluding 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 to transform it before saving to Sanity. Multiple transformers can be stacked and run sequentially.
The plugin offers no way to test transformers in isolation, so development can be complex. Save real target documents from the Sanity dataset to .JSON and use them as test data for each of encode/decode function.
Example of modifying JSON-encoded VTT files to HTML so Phrase can better segment the content of subtitles:
import { DataTransformer } from 'sanity-plugin-phrase'
const vttJsonTransformer: DataTransformer = {
encode: {
array(arr) {
// Check if array contains VTT subtitle nodes
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 // Return undefined to skip transformation
},
},
decode: {
object(obj) {
if (!!obj && '_type' in obj && obj._type === 'encodedSubtitles') {
return decodeSubtitles(obj as EncodedSubtitles)
}
return undefined
},
},
}
export const PHRASE_CONFIG = definePhraseOptions({
// ...
dataTransformers: [vttJsonTransformer],
})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
Limiting editor access
Which editors can access the Phrase dashboard can be limited by implementing the i option. This function is equivalent to the one passed to the hidden property of a field in Sanity. It receives a context with the current user and document and must return a boolean.
Example of limiting 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
},
})