
Google Sheets as a Translation Database for Web and Mobile Apps
i18n is the part of every web project that nobody enjoys. The developer wants the strings in the code, where they can be type-checked. The translator wants them in a familiar tool, not a Git pull request. The product manager wants to see all locales at a glance. The marketing team wants to fix a typo in 30 seconds without filing a ticket.
The standard answers — Crowdin, Lokalise, Phrase, Transifex — solve the problem by becoming the system of record. They are excellent at scale. They are also $50–$500 per month, require an extra login, and turn every two-word copy fix into a four-step workflow.
For most small and mid-sized projects there is a forgotten option: a single Google Sheet, an Apps Script web app, and a 60-line client cache. The translator gets a familiar grid. The developer gets typed JSON. The marketing team gets instant edits. Total cost: $0 to $6 per month for Workspace.
This guide shows the architecture end to end — schema, Apps Script endpoint, frontend integration, fallback locale, missing-key alerts, and the limits where you should actually pay for Lokalise instead.
Why a Sheet Beats a Translation Service for Small Projects
Localization tools are designed for the case where you have 5,000+ keys, twenty translators with role-based access, and a CAT engine that suggests fuzzy matches from past projects. Most websites are not that case. A typical SaaS marketing site has 200–600 strings. A small e-commerce store has 100–300. A landing page has 30–80. At that scale, a CAT engine is overkill, and the friction of a separate tool dominates the savings.
A Google Sheet, by contrast, is:
- Familiar. Anyone who has filled out an expense report can edit a translation cell.
- Version-controlled. Sheets keeps every revision automatically — you can see "translator changed FR welcome string at 3:47 PM" without a CMS audit log.
- Multi-translator-safe. Real-time co-editing, comments, and suggestions are built in.
- Serializable. A
tools/exportLocales.jsscript can pull the same data to flat JSON files for production deploys, so you are never blocked on Apps Script availability. - Free or nearly so. A personal Google account costs $0; Workspace Business Starter is $6/user/month.
The trade-off: this stops being the right pattern past about 1,000 keys, or when you need professional CAT features (translation memory, glossaries, in-context preview, machine translation hints). We address that boundary at the end.
The Schema: One Sheet, One Tab, One Row Per Key
Keep it simple. One tab named strings. First row is the header. First column is the key. Every other column is a locale.
key | en | tr | es | fr
homepage.title | Welcome | Hoş geldiniz | Bienvenido | Bienvenue
homepage.cta | Get started | Başla | Empezar | Commencer
auth.login.button | Log in | Giriş yap | Iniciar sesión | Connexion
auth.error.401 | Invalid login | Geçersiz giriş | Inicio inválido | Connexion invalide
The keys use dot notation (section.subsection.element) so you can group related strings visually with a filter. Empty cells signal "not yet translated" — the API endpoint will fall back to the default locale automatically.
A second optional tab named meta holds a single row: defaultLocale = en, lastUpdated = <auto>, version = 1.4.2. This lets the client know whether to refresh its cache without re-downloading every locale.
The Apps Script Endpoint
Open Extensions → Apps Script and write a doGet that serializes the Sheet into nested JSON.
// Code.gs
const SHEET_ID = 'your-sheet-id';
function doGet(e) {
const locale = (e.parameter.locale || 'all').toLowerCase();
const result = buildLocaleData(locale);
return ContentService
.createTextOutput(JSON.stringify(result))
.setMimeType(ContentService.MimeType.JSON);
}
function buildLocaleData(locale) {
const ss = SpreadsheetApp.openById(SHEET_ID);
const sheet = ss.getSheetByName('strings');
const data = sheet.getDataRange().getValues();
const headers = data[0]; // ['key', 'en', 'tr', 'es', 'fr']
const localeIdx = {};
headers.forEach((h, i) => { if (i > 0) localeIdx[String(h).toLowerCase()] = i; });
const meta = ss.getSheetByName('meta');
const defaultLocale = meta.getRange('B1').getValue() || 'en';
const version = meta.getRange('B3').getValue() || '1.0.0';
if (locale !== 'all' && !localeIdx[locale]) {
return { error: 'Unknown locale', available: Object.keys(localeIdx) };
}
const targets = locale === 'all' ? Object.keys(localeIdx) : [locale];
const out = { locale, defaultLocale, version, strings: {} };
if (locale === 'all') {
targets.forEach(l => out.strings[l] = {});
}
for (let row = 1; row < data.length; row++) {
const key = data[row][0];
if (!key) continue;
if (locale === 'all') {
targets.forEach(l => {
out.strings[l][key] = data[row][localeIdx[l]] || data[row][localeIdx[defaultLocale]];
});
} else {
out.strings[key] = data[row][localeIdx[locale]] || data[row][localeIdx[defaultLocale]];
}
}
return out;
}
Deploy as a web app: Execute as: Me, Who has access: Anyone. You will receive a URL of the form https://script.google.com/macros/s/.../exec. That URL is your translation API.
A request to ?locale=tr returns:
{
"locale": "tr",
"defaultLocale": "en",
"version": "1.4.2",
"strings": {
"homepage.title": "Hoş geldiniz",
"homepage.cta": "Başla"
}
}
The fallback is automatic: any cell empty in the requested locale falls back to the default locale's value, so a partially-translated locale never breaks the UI. We covered the deployment mechanics in our Apps Script webhooks guide — same doGet pattern, different payload shape.
Frontend Integration: Build-Time Sync for Next.js
In a Next.js App Router project, fetch the JSON at build time and write it to disk. This keeps Apps Script out of the runtime path entirely — your Apps Script web app is just a build-time CMS.
// scripts/sync-locales.ts
import fs from 'fs';
import path from 'path';
const URL = process.env.LOCALE_SOURCE_URL!;
const LOCALES = ['en', 'tr', 'es', 'fr'];
async function sync() {
for (const locale of LOCALES) {
const res = await fetch(`${URL}?locale=${locale}`);
const data = await res.json();
const out = path.join(process.cwd(), 'public', 'locales', `${locale}.json`);
fs.writeFileSync(out, JSON.stringify(data.strings, null, 2));
console.log(`✓ ${locale}: ${Object.keys(data.strings).length} strings`);
}
}
sync();
Run npm run sync-locales before every deploy and your public/locales/tr.json is fresh. A small useTranslations(locale) hook reads the right JSON at render time. No runtime call to Apps Script, no cache to invalidate, no Apps Script downtime risk.
For sites that need live translation updates (CMS-style), call the Apps Script URL from an edge function with a 5-minute cache header. The marketing team edits the Sheet at 10:03, the live site picks up the change by 10:08, no deploy needed. We covered the same edge-cache-on-top-of-Apps-Script pattern for custom Apps Script frontends — translation is one of the cleanest applications.
Caching, Versioning, and Missing Keys
Three production hardening rules that pay off:
Cache aggressively at the edge. Apps Script web apps comfortably handle 50–100 requests per second, but you should never make Sheets your live runtime dependency. Wrap the response in a 5-minute or 1-hour edge cache (Vercel Edge, Cloudflare, Next.js revalidate). Translators see a small lag; users see a fast site.
Pin versions, not timestamps. When you bump the meta.version cell, a deployed client compares its cached version and decides whether to refetch. This avoids the "always-stale" problem of timestamp-based caching.
Alert on missing keys. A small Apps Script trigger can scan the strings sheet daily, find any key missing in any locale, and email the translator. Five lines of code, prevents the "we shipped FR with three blank buttons" incident:
function alertMissingKeys() {
const ss = SpreadsheetApp.openById(SHEET_ID);
const data = ss.getSheetByName('strings').getDataRange().getValues();
const headers = data[0];
const missing = [];
for (let r = 1; r < data.length; r++) {
headers.forEach((h, c) => {
if (c > 0 && !data[r][c]) missing.push(`${data[r][0]} → ${h}`);
});
}
if (missing.length) {
MailApp.sendEmail('translators@example.com',
`${missing.length} missing translations`,
missing.join('\n'));
}
}
The Translator Workflow
The reason this pattern wins for small teams is that it removes friction at every step.
A translator opens the Sheet (one click from a bookmark), filters the column they own, types translations into empty cells, and walks away. No PR, no commit, no Slack thread asking the developer to push their changes. Sheet revision history captures everything for audit. If the translator overwrites something incorrectly, you restore from version history in two clicks.
For larger teams, you can layer in suggested edits (translators submit suggestions, a reviewer approves) and named ranges to control which locales each translator can write to. Both come for free with Workspace permissions — you do not need to build them.
When to Stop Using Sheets and Pay for Lokalise
This pattern breaks down at:
- 1,000+ keys — the Sheet becomes slow to scroll, search becomes painful
- 5+ translators — coordination problems start to outweigh the simplicity
- Right-to-left languages with complex ICU plural rules — Sheets has no plural-form awareness
- Marketing copy needing in-context preview — you cannot see the actual button while typing the translation
- Regulated industries needing translation audit trails — Sheets revisions exist but are not export-friendly
Below those thresholds, the Sheet wins. Above them, pay for Crowdin or Lokalise — the workflow tooling justifies the cost. The migration path is straightforward: export the Sheet as CSV, import into the new tool, ship.
We have helped teams set up the Sheet-based pattern for a Next.js marketing site (4 locales, 240 keys), a secure client portal for a B2B service (2 locales, 80 keys), and a mobile app's onboarding flow (6 locales, 60 keys). The setup time is a single afternoon. The maintenance burden is a single tab. The same architecture also pairs naturally with replacing more SaaS with Google Workspace — translations are one of the easiest wins.
If you want this set up for your project, MageSheet's consulting practice ships the full pattern — Sheet, Apps Script endpoint, build script, edge cache, and missing-key alerts — in a single engagement.
Frequently Asked Questions
Will my Sheet endpoint stay reliable enough to be a live translation source for production traffic?
Apps Script web apps comfortably handle bursts up to 50–100 requests per second, but they are not engineered for production traffic. The recommended pattern is to fetch translations at build time (or at most every 5 minutes through an edge cache) and serve them from your CDN. Apps Script then becomes the editing layer, not the runtime layer. If your edge cache fails open, your site keeps working with the last good JSON, so a transient Apps Script outage never breaks a user request.
How do I support pluralization (1 item vs 2 items) with this approach?
The simplest approach is one row per plural form: cart.items.zero, cart.items.one, cart.items.other. Your client picks the right key based on the count. For full ICU MessageFormat support — which handles dozens of languages with non-trivial plural rules like Russian, Polish, or Arabic — you store the ICU template in the cell value and parse it client-side with @formatjs/intl-messageformat. Sheets is the storage; the formatting logic stays in your app.
Can translators edit the Sheet without ever seeing my product code?
Yes — share the Sheet with editor access and restrict your code repo separately. Translators only ever see strings, never logic. For an extra layer of context, add a comments column where the developer writes notes ('this appears on the checkout button after a successful payment') so translators have context without seeing the surrounding UI. Sheets' built-in cell comments work for back-and-forth questions.
What about right-to-left languages like Arabic and Hebrew?
RTL is fully supported as long as you treat the locale code correctly in your frontend (set dir="rtl" on the html element for ar, he, fa). The Sheet stores right-to-left text identically to LTR — it is just Unicode, and the Sheets cell editor handles RTL display natively. The hard part of RTL is layout, not data, and that lives in your CSS, not in your translation database.
How do I handle interpolation like 'Hello, {name}' in this pattern?
Use {placeholder} syntax in the cell value: homepage.greeting = "Hello, {name}!". Your client interpolates at render time. This is exactly the pattern react-intl, i18next, and next-intl use, so any translation file produced with this Sheet pattern is drop-in compatible with those libraries — you can migrate to one later without rewriting strings. The only translator-side rule is 'do not translate anything inside curly braces,' which fits in a single line of the comments column.



