Docs · Guide

Translate your website

Show your whole website in your visitors' languages. This guide shows the way that works well: pick the right language for each visitor, translate each piece of text once, and keep it.

Choose each visitor's language

When someone arrives, pick their language in this order:

  1. The language they chose before. Keep it in a small cookie when they use your language switcher. Their own choice always wins.
  2. Their browser's languages. Every browser sends an Accept-Language header, such as fr-CH, fr;q=0.9, en;q=0.8. It lists the languages the person reads, most wanted first. Take the first one your site has. If you don't have fr-CH, try fr.
  3. Where they are, as a last hint. Your hosting or network provider can add a header with the visitor's country. Use it only when the first two give nothing.

Then fall back to your site's main language.

Why location alone is a weak signal

  • Most countries have many languages. A visitor in Nigeria may read English, Yoruba, Hausa or Igbo.
  • People travel, and work and study abroad. Someone in London may read French.
  • Some networks route visitors through another country, so the country can simply be wrong.

The browser's languages are set by the person, so they are a much better guide.

Always offer a switcher

Even a good guess is sometimes wrong. Put a language switcher on every page, and show each language in its own name (Français, not French). When someone picks one, remember it in the cookie for a year.

HTML
<nav aria-label="Language">
  <a href="/?lang=en" hreflang="en" lang="en">English</a>
  <a href="/?lang=fr" hreflang="fr" lang="fr">Français</a>
  <a href="/?lang=yo" hreflang="yo" lang="yo">Yorùbá</a>
</nav>

Only choose for the visitor on an address without a language, such as your home page. If someone opens a French address, show French, whatever their browser says. The examples are at the end of this guide.

Translate once, then keep it

Your pages change rarely, but they're read often. So translate each piece of text once, keep the translation, and show the kept copy every time after that.

  • Keep each piece of text, per language. Save the translation with its language and the original text, or a fingerprint (hash) of it.
  • Translate again only when the text changes. When you edit a paragraph, its fingerprint changes, so only that paragraph is sent again. Everything else comes from your copy.
  • Translate your main languages when you publish. Send each new or changed piece of text once, with up to 10 languages in one request (Many languages at once). Visitors then never wait for a translation.
  • Send the language your site is written in as from. Short text, like a button, is hard to tell apart otherwise.

How this keeps you inside your allowance

Characters are counted each time you send text: the length of the text times the number of languages. A 3,000-character page in 5 languages costs 15,000 characters, once. If the page were translated on every visit instead, 100 visits a day would cost 1,500,000 characters a day.

Your allowance is 100,000 characters every 30 days. Translated once and kept, that is about 6 such pages a month. Translated on every visit, it would not last a single day. Every answer tells you what is left in the Neo-Characters-Remaining header. Need more for a large website? Please talk to us.

Call from your server only

Always send your requests from your website's server, never from code that runs in the visitor's browser. Anything in a web page can be read by anyone who opens it, so your key would no longer be private.

  • Keep the key in your server's settings (an environment variable) where you can, not in your pages or your code.
  • Your pages show the kept translations. Visitors never talk to Neo Translate themselves.
  • If a key ever gets out, turn it off and make a new one.

No server settings? (shared hosting, files by FTP only)

Keep the key in a small .php file that returns it, such as nt-key.php. A .php file is run, not shown, so a browser never sees what's in it. Put it above your website folder if you can, so it can't be opened from the web at all.

nt-key.php
<?php return 'nt_live_…';

Then read it where you need it. In the PHP example below, use it in place of the server setting:

PHP
// nt-key.php is one folder above this website's folder
$key = require __DIR__ . '/../nt-key.php';

// In neo_translate.php, instead of getenv('NEO_TRANSLATE_KEY'):
'Authorization: Bearer ' . $key,

Translate text, not whole pages

Don't send a whole web page with its HTML. Send the words: each heading, paragraph, button label and menu item as its own piece of text. Then put each translation back where the original was.

  • Your layout, links and styles stay exactly as they are, in every language.
  • A short piece, like a button, is kept on its own. When the same words appear on many pages, they're translated once.
  • A long piece must be under 5,000 characters. Split long articles by paragraph.

Leave these as they are

  • Web and email addresses, phone numbers and dates written as numbers.
  • Code, and anything a computer reads, such as form field names.
  • Your church and brand names, when they should stay the same in every language.

Languages written right to left, such as Arabic, need dir="rtl" on the page. The language list tells you which ones.

One address per language

Give each language its own address: /fr/ for French, /yo/ for Yoruba, and so on. Then people can share a link in their language, and search engines can list every version.

On every version, list all the others with hreflang tags. Search engines then show each person the version in their language. Use x-default for the page that chooses for the visitor.

HTML
<!-- On every language version of the page, list them all, including itself -->
<link rel="alternate" hreflang="en" href="https://example.org/en/about/">
<link rel="alternate" hreflang="fr" href="https://example.org/fr/about/">
<link rel="alternate" hreflang="yo" href="https://example.org/yo/about/">
<link rel="alternate" hreflang="x-default" href="https://example.org/about/">

<!-- And say which language this page is in -->
<html lang="fr">

Don't switch languages at the same address depending on who asks. Search engines would only ever see one of them.

Complete examples

Working code in PHP, Node.js and Python. It uses only what comes with each language, with nothing to install. Put your key in the server setting NEO_TRANSLATE_KEY.

Translate with a cache

translate gives one piece of text in one language: from your saved copy, or translated once and saved. pretranslate is for when you publish: it asks only for the languages you don't have yet. The copies are saved as small files, one per piece of text and language. Save them as neo_translate.php, neo-translate.mjs or neo_translate.py.

<?php
// neo_translate.php: translate with a cache. PHP 7.4 or later, with curl.
// Your key comes from the server's settings (NEO_TRANSLATE_KEY), never from a web page.

const NEO_API = 'https://api.neotranslate.org/v1/translate';
const SOURCE_LANG = 'en'; // the language your site is written in
define('CACHE_DIR', getenv('TRANSLATION_CACHE_DIR') ?: __DIR__ . '/translations');

// One file per piece of text and language. The text is part of the name, so
// changed text is translated again, and unchanged text never is.
function neo_cache_file(string $text, string $to): string {
    return CACHE_DIR . '/' . $to . '/' . hash('sha256', SOURCE_LANG . '|' . $to . '|' . $text) . '.json';
}

function neo_save(string $text, string $to, string $translation): void {
    $file = neo_cache_file($text, $to);
    if (!is_dir(dirname($file))) mkdir(dirname($file), 0775, true);
    file_put_contents($file, json_encode(['text' => $text, 'translation' => $translation]));
}

function neo_call(array $body): array {
    for ($attempt = 1; ; $attempt++) {
        $wait = 5;
        $ch = curl_init(NEO_API);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . getenv('NEO_TRANSLATE_KEY'),
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode(['from' => SOURCE_LANG] + $body),
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$wait) {
                if (stripos($line, 'Retry-After:') === 0) $wait = (int) trim(substr($line, 12));
                return strlen($line);
            },
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        $data = json_decode((string) $raw, true);
        if ($status === 200) return $data;
        if ($status === 429 && $attempt < 3) { sleep(max(1, $wait)); continue; } // slow down, then try again
        throw new RuntimeException($data['error']['message'] ?? 'Translation failed (' . $status . ')');
    }
}

/** One piece of text in one language: from the cache, or translated once and kept. */
function neo_translate(string $text, string $to): string {
    if ($to === SOURCE_LANG || trim($text) === '') return $text;
    $file = neo_cache_file($text, $to);
    if (is_file($file)) return json_decode(file_get_contents($file), true)['translation'];
    $data = neo_call(['text' => $text, 'to' => $to]);
    neo_save($text, $to, $data['translation']);
    return $data['translation'];
}

/** When you publish: one piece of text into your main languages. Only missing ones are asked for. */
function neo_pretranslate(string $text, array $languages): int {
    $missing = array_values(array_filter($languages, function ($to) use ($text) {
        return $to !== SOURCE_LANG && !is_file(neo_cache_file($text, $to));
    }));
    $saved = 0;
    foreach (array_chunk($missing, 10) as $group) { // up to 10 languages a request
        $data = neo_call(['text' => $text, 'to' => $group]);
        foreach ($data['translations'] as $t) {
            if (isset($t['error'])) continue; // not available right now: the next run tries again
            neo_save($text, $t['to'], $t['translation']);
            $saved++;
        }
    }
    return $saved;
}

// In a page:        echo htmlspecialchars(neo_translate('Welcome to our church', $lang));
// When you publish: neo_pretranslate($paragraph, ['fr', 'yo', 'sw', 'pt']);

Choose the visitor's language

Their saved choice, then their browser, then their country, then your main language. Change SUPPORTED to your languages, and the country header to the name your provider uses.

<?php
// choose_language.php: pick the visitor's language, and remember their choice.

const SUPPORTED = ['en', 'fr', 'yo', 'sw', 'pt']; // the languages your site is published in
const DEFAULT_LANG = 'en';
const LANG_COOKIE = 'lang';
// The country header your hosting or network provider adds. Ask them for its name.
const COUNTRY_HEADER = 'HTTP_X_COUNTRY';
// A hint only: most countries have many languages, and people travel.
const BY_COUNTRY = ['FR' => 'fr', 'CI' => 'fr', 'CM' => 'fr', 'BJ' => 'fr', 'KE' => 'sw',
                    'TZ' => 'sw', 'BR' => 'pt', 'PT' => 'pt', 'AO' => 'pt', 'MZ' => 'pt'];

/** "yo" or "YO" -> "yo", if your site has it; otherwise null. */
function supported_lang(?string $code): ?string {
    foreach (SUPPORTED as $s) {
        if ($code !== null && strcasecmp($s, $code) === 0) return $s;
    }
    return null;
}

/** "fr-CH, fr;q=0.9, en;q=0.8" -> ['fr-ch', 'fr', 'en'], most wanted first. */
function browser_languages(string $header): array {
    $langs = [];
    foreach (explode(',', $header) as $i => $part) {
        $bits = array_map('trim', explode(';', $part));
        $q = 1.0;
        foreach (array_slice($bits, 1) as $p) {
            if (strpos($p, 'q=') === 0) $q = (float) substr($p, 2);
        }
        if ($bits[0] !== '' && $bits[0] !== '*' && $q > 0) $langs[] = [strtolower($bits[0]), $q, $i];
    }
    usort($langs, function ($a, $b) { return [$b[1], $a[2]] <=> [$a[1], $b[2]]; });
    return array_column($langs, 0);
}

function choose_language(?string $cookie, string $acceptLanguage, string $country): string {
    if ($lang = supported_lang($cookie)) return $lang;                     // 1. their own choice
    foreach (browser_languages($acceptLanguage) as $tag) {                  // 2. their browser
        if ($lang = supported_lang($tag)) return $lang;
        if ($lang = supported_lang(explode('-', $tag)[0])) return $lang;
    }
    return supported_lang(BY_COUNTRY[strtoupper($country)] ?? null) ?? DEFAULT_LANG; // 3. where they are
}

/** The language switcher sends ?lang=fr: remember it for a year. */
function remember_language(string $lang): void {
    if (supported_lang($lang)) {
        setcookie(LANG_COOKIE, $lang, ['expires' => time() + 365 * 86400, 'path' => '/', 'samesite' => 'Lax']);
    }
}

// On your home page (an address without a language), send the visitor to theirs:
//   if (isset($_GET['lang'])) remember_language($_GET['lang']);
//   $lang = choose_language($_GET['lang'] ?? $_COOKIE[LANG_COOKIE] ?? null,
//                           $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '', $_SERVER[COUNTRY_HEADER] ?? '');
//   header('Location: /' . $lang . '/', true, 302);

The call and its answer are the ones in Translate text: POST /v1/translate with Authorization: Bearer and your key, and the answer's translation, or translations for many languages.