Five production bugs from running a 20-language AI song generator on Next.js
I run a small AI song generator: you paste lyrics, pick a style, and get a sung track back. It is a Next.js 14 App Router app with next-intl (20 locales), PostgreSQL, Cloudflare R2 for audio, and a third-party music API that calls back when a track is ready. This month I spent a few days fixing thi

I run a small AI song generator: you paste lyrics, pick a style, and get a sung track back. It is a Next.js 14 App Router app with next-intl (20 locales), PostgreSQL, Cloudflare R2 for audio, and a third-party music API that calls back when a track is ready.
This month I spent a few days fixing things that had been quietly wrong for a long time. None of them showed up as errors in the UI. Here are the five that were worth writing down.
Uploading the generated MP3 to R2 with the S3 API worked from a plain Node script. The same code inside a route handler failed:
411 MissingContentLength: You must provide the Content-Length HTTP header. Enter fullscreen mode Exit fullscreen mode The request was signed correctly. The problem was the transport: inside the Next.js server runtime, fetch (which Next.js patches) sent the Buffer body as a stream without a Content-Length header. R2 does not accept chunked uploads for a plain PUT.
The fix was to keep aws4fetch for signing only, and send the request with a client that sets Content-Length from the buffer:
import { AwsClient } from " aws4fetch "; import axios from " axios "; const client = new AwsClient ({ accessKeyId, secretAccessKey, service: " s3 ", region: " auto " }); export async function uploadToR2 ( key: string, body: Buffer ) { const url = `https:// ${ accountId }.r2.cloudflarestorage.com/ ${ bucket } / ${ encodeURI ( key )} `; // sign only; don't let the patched fetch send it const signed = await client. sign ( url, { method: " PUT ", body, headers: { " Content-Type ": " audio/mpeg ", " Cache-Control ": " public, max-age=31536000, immutable " }, }); await axios. put ( url, body, { headers: Object. fromEntries ( signed. headers. entries ()), maxBodyLength: Infinity, }); } Enter fullscreen mode Exit fullscreen mode The nasty part: my code caught the error and fell back to the external API's temporary URL, so songs played fine for a few days and then 404'd when those URLs expired. If you have a fallback path, log loudly when you take it.
Analytics showed 709 page views for 714 sessions and more new users than users. In other words, almost nothing after the landing page was recorded.
The fix is to default to denied only in the regions that need it, store the actual choice, and re-apply it before config on every load:
Free-plan songs are now kept for 10 days. I added a column and a daily cleanup job:
alter table works add column storage_expire_at timestamptz; create index idx_works_storage_expire_at on works ( storage_expire_at ) where storage_expire_at is not null; Enter fullscreen mode Exit fullscreen mode New free songs get now() + interval '10 days', and paid users get NULL (kept forever). Then I backfilled existing free songs, expecting three rows. The UPDATE touched 2,122.
My WHERE clause filtered on the user's plan and the song's status, but not on whether the audio still existed. Thousands of old songs whose files were long gone still had status = completed. Nothing was deleted, because the cleanup job had not run yet, and I reverted the 2,119 extra rows right away. Two habits came out of it:
The music API calls POST /api/backend/sunoapi/<work id> when a track is done. That endpoint used to trust any caller, so anyone who guessed an id could mark a song as finished with an arbitrary audio URL.
The provider doesn't sign its callbacks, so I sign the URL instead. The callback URL I register carries an HMAC of the work id, and the handler checks it in constant time:
const token = ( uid: string ) => crypto. createHmac ( " sha256 ", process. env. CALLBACK_SECRET! ). update ( uid ). digest ( " hex " ); // when submitting the job const callBackUrl = `https://example.com/api/backend/sunoapi/ ${ uid }?token= ${ token ( uid )} `; // in the callback handler const a = Buffer. from ( searchParams. get ( " token " ) || "" ); const b = Buffer. from ( token ( uid )); if ( a. length!== b. length ||! crypto. timingSafeEqual ( a, b )) return new Response ( " forbidden ", { status: 403 }); Enter fullscreen mode Exit fullscreen mode The handler also checks that the task_id in the body matches the one stored for that work, so a valid token can't be replayed against a different job.
With localePrefix: "as-needed", every [locale] route exists in all 20 languages by default. I added a "make a song from your lyrics" landing page, but only wrote content for 12 languages. Rendering the other 8 with fallback English text would create thin duplicate pages.
The page now calls notFound() when a locale has no content, and the <head> component only emits hreflang links for the locales that do:
const content = songFromLyrics [ locale ]; if (! content ) notFound (); // … < HeadInfo page = "/song-from-lyrics" alternateLocales = { Object. keys ( songFromLyrics ) } /> Enter fullscreen mode Exit fullscreen mode The sitemap builds its entries from the same object, so the page list, the hreflang set and the sitemap can't drift apart.
If you want to see the result, the app is AISong.Fun. Paste a few lines of lyrics, and it sings them in your language.