DEV Community

anders lee
anders lee

Posted on

A cron job adds a new language to my Next.js app every three days

I run a small AI song generator in 22 languages, built on Next.js 14 with next-intl. Thirteen more languages are in the queue. Adding one is boring, and it is also easy to get subtly wrong, so I stopped doing it myself.

Now a daily cron job checks a queue. Every three days it starts a headless coding agent (Claude Code), which adds the next language, deploys it, checks it and mails me the result. The first fully unattended run added Norwegian this week.

The agent was the easy part. The work went into deciding what it must check, and what it does when something fails.

What one language actually touches

The Dutch locale was the last one I added in an interactive session. Its commit changed seven files and about 570 lines:

messages/nl.json              | 267 +++
scripts/x-post-song.mjs       |   2 +-
src/config.ts                 |  10 +-
src/content/birthdaySong.ts   | 199 +++
src/content/songFromLyrics.ts |  95 +++
src/middleware.ts             |   2 +-
src/servers/gift.ts           |   2 +-
Enter fullscreen mode Exit fullscreen mode

Only the first file is what most people mean by "translation". The middleware matcher, the locale list and the language name maps are each a line that is easy to forget, and each one fails in a different way.

That commit became the spec. The playbook starts by telling the agent to copy what commit 17a27e6 did and do nothing else, then lists every file by name. A real diff is a far better instruction than a description of one, because it shows the exact shape of each change.

The queue is a data file

{
  "queue": [
    { "code": "da", "hreflang": "da-DK", "country": "dk", "name": "Dansk", "english": "Danish" },
    { "code": "he", "hreflang": "he-IL", "country": "il", "name": "עברית", "english": "Hebrew", "rtl": true }
  ],
  "done": [
    { "code": "nl", "date": "2026-11-02", "commit": "17a27e6" },
    { "code": "no", "date": "2026-11-05", "commit": "6a4be3a" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The order is a business decision: languages come first where people are more likely to pay. The rtl flag sends the agent to one extra step in the layout. The done list doubles as the scheduler's state, so there is no second place to keep in sync.

The cron wrapper stays dumb on purpose. No AI runs unless all of these pass (trimmed, prompt translated):

read -r DAYS LEFT < <(python3 -c "
import json, datetime
q = json.load(open('scripts/locale-queue.json'))
last = max(datetime.date.fromisoformat(d['date']) for d in q['done'])
print((datetime.date.today() - last).days, len(q['queue']))")

[ "$LEFT" -eq 0 ] && exit 0                      # queue empty
[ "$DAYS" -lt 3 ] && exit 0                      # too soon
pgrep -f "add-locale.md" >/dev/null && exit 0    # already running

timeout 3h claude -p "Follow the steps in scripts/add-locale.md." >> "$LOG" 2>&1
RC=$?
[ $RC -ne 0 ] && mail_me "locale run exited with $RC"
Enter fullscreen mode Exit fullscreen mode

Three days is not a technical limit. It leaves me time to notice a bad language before the next one lands on top of it.

Check translations with code, not with the model

Asking a model whether its own translation is complete tells you very little. A short script does better. It compares every key with en.json and makes sure placeholders survive:

import { readFileSync } from "node:fs";

const flat = (o, p = "") =>
  Object.entries(o).flatMap(([k, v]) =>
    v && typeof v === "object" ? flat(v, `${p}${k}.`) : [[`${p}${k}`, String(v)]]
  );
const load = (l) => new Map(flat(JSON.parse(readFileSync(`messages/${l}.json`, "utf8"))));

const en = load("en");
const target = load(process.argv[2]);
const errors = [];

for (const [key, value] of en) {
  if (!target.has(key)) { errors.push(`missing: ${key}`); continue; }
  for (const token of ["%s", "&quot;"]) {
    if (value.includes(token) && !target.get(key).includes(token)) {
      errors.push(`lost ${token}: ${key}`);
    }
  }
}
for (const key of target.keys()) if (!en.has(key)) errors.push(`extra: ${key}`);

console.log(errors.length ? errors.join("\n") : `ok: ${en.size} keys`);
process.exit(errors.length ? 1 : 0);
Enter fullscreen mode Exit fullscreen mode

On the real Norwegian file it prints ok: 257 keys. On a copy I broke on purpose:

missing: IndexPage.title
lost %s: IndexPage.myDeleteIn
extra: Extra.x
Enter fullscreen mode Exit fullscreen mode

The second line is the one that matters. A dropped %s is valid JSON and passes a key comparison, and users only see it as a countdown with no number in it.

Don't translate the title

The instruction I care about most is the one that breaks "translate this file": titles and headings must be written the way people in that country search, not converted from English.

Locale Home page title
en Free AI Song Generator — Make a Song from Your Lyrics
nl Gratis AI liedje maken — maak een lied van je eigen tekst
no Lag sang med AI gratis — gjør din egen tekst til en sang
uk Створити пісню за допомогою ШІ безкоштовно онлайн

English leads with the noun "generator". The other three lead with a verb, "make a song with AI", because that is the phrase people type. About three quarters of my Google clicks land on the Ukrainian home page, whose title is written this way.

Two more rules came from things that are invisible in a diff:

  • FAQ text must describe the product as it is. A translation can stay accurate word for word and still promise something, so the playbook spells out what is free and what is paid, instead of leaving the agent to infer it.
  • Button names quoted in copy must match the real button labels. "Click Make the song" is useless if the button was translated differently in another file.

There is a data trap too. Example songs are looked up by locale and title, so a title reused on two landing pages silently shows the wrong song. The playbook forbids it by name, because neither the build nor the key check would catch it.

"It returned 200" is not a check

An earlier page of mine returned 200 while its content was squashed under the footer. A status code says nothing about what a visitor sees, so after every deploy the agent has to:

  1. Request the five new pages, plus the Ukrainian home page, which is the one I can least afford to break.
  2. Confirm that the new language appears in hreflang and in the sitemap.
  3. Take mobile-width Playwright screenshots of the new pages and actually look at them: not blank, and in the right language.

Decide the failure path before the run

An unattended agent needs to know what "stop" means. The playbook has two cases:

  • Before deploy: restore the working tree and mail me the reason. Never deploy half a language.
  • After deploy: if a page is blank or returns 500, git revert, deploy again, confirm the main page is back, then mail me.

The deploy script is the safety net under both. It builds into a separate directory while the site keeps serving, swaps directories, does a rolling reload, and rolls back to the previous build if the health check fails. A failed build never touches production.

There are also hard limits written down: don't touch secrets, don't change prices or other features, and don't restart the process manager by hand. The agent runs without anyone approving its steps, so the playbook has to carry those limits itself.

What this doesn't solve

  • Nobody who speaks Norwegian has reviewed the Norwegian. The checks prove the file is complete and the pages render. They don't prove it reads naturally.
  • Costs need their own limit. The first version generated five example songs per language with a paid music API. It only checked the remaining balance, not whether spending it was still a good idea, so I paused that step and the playbook now skips it.
  • It's too early to say whether this pays off. One unattended run isn't a track record, and the two newest languages have no meaningful search data yet.

So far the useful lesson is about the shape of the job. Take one real diff, turn it into a named list of steps, check the mechanical parts with code, and write down the failure path before anything runs. You can see the output of a run on the Norwegian birthday song page.

Written with AI assistance. The code and the numbers come from the project's repository.

Top comments (0)