{"slug": "five-production-bugs-from-running-a-20-language-ai-song-generator-on-next-js", "title": "Five production bugs from running a 20-language AI song generator on Next.js", "summary": "A developer running a 20-locale AI song generator on Next.js 14 documented five silent production bugs, including a 411 MissingContentLength error caused by Next.js's patched fetch sending Buffer bodies without a Content-Length header to Cloudflare R2, and a consent-mode misconfiguration that left analytics_storage denied for all visitors and dropped nearly all post-landing-page tracking. The fixes involved signing R2 uploads with aws4fetch but sending them via axios with an explicit Content-Length, and re-applying stored consent choices before gtag config on every page load. A storage-expiry backfill also unexpectedly updated 2,122 rows instead of three, prompting the developer to recommend running a matching SELECT count(*) before any UPDATE.", "body_md": "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.\n\nThis 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.\n\n`411 MissingContentLength`, but only inside Next.js\nUploading the generated MP3 to R2 with the S3 API worked from a plain Node script. The same code inside a route handler failed:\n\n```\n411 MissingContentLength: You must provide the Content-Length HTTP header.\n```\n\nThe 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`.\n\nThe fix was to keep `aws4fetch` for signing only, and send the request with a client that sets `Content-Length` from the buffer:\n\n``` python\nimport { AwsClient } from \"aws4fetch\";\nimport axios from \"axios\";\n\nconst client = new AwsClient({ accessKeyId, secretAccessKey, service: \"s3\", region: \"auto\" });\n\nexport async function uploadToR2(key: string, body: Buffer) {\n  const url = `https://${accountId}.r2.cloudflarestorage.com/${bucket}/${encodeURI(key)}`;\n  // sign only; don't let the patched fetch send it\n  const signed = await client.sign(url, {\n    method: \"PUT\",\n    body,\n    headers: { \"Content-Type\": \"audio/mpeg\", \"Cache-Control\": \"public, max-age=31536000, immutable\" },\n  });\n  await axios.put(url, body, {\n    headers: Object.fromEntries(signed.headers.entries()),\n    maxBodyLength: Infinity,\n  });\n}\n```\n\nThe 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.\n\nAnalytics showed 709 page views for 714 sessions and more new users than users. In other words, almost nothing after the landing page was recorded.\n\nTwo bugs together:\n\n`analytics_storage` to `denied` for every visitor worldwide, not just where it is required.`cookieConsent=true` on both Accept and Decline, and only called `gtag('consent', 'update', …)` at the moment of the click. On the next page load the stored choice was never re-applied, so consent was back to 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:\n\n```\ngtag('consent', 'default', { analytics_storage: 'denied', region: ['AT','BE', /* … EEA, */ 'GB','CH'] });\ngtag('consent', 'default', { analytics_storage: 'granted' });\nvar m = document.cookie.match(/(?:^|; )cookieConsent=(granted|denied)/);\nif (m) gtag('consent', 'update', { analytics_storage: m[1] });\ngtag('config', GA_ID);\n```\n\nOld `cookieConsent=true` cookies are treated as \"no choice yet\", so those visitors see the banner once more. That is the honest option, since the old value can't tell you which button they pressed.\n\nFree-plan songs are now kept for 10 days. I added a column and a daily cleanup job:\n\n```\nalter table works add column storage_expire_at timestamptz;\ncreate index idx_works_storage_expire_at on works (storage_expire_at)\n  where storage_expire_at is not null;\n```\n\nNew 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.\n\nMy `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:\n\n`SELECT count(*)` with the exact same `WHERE` first, and compare it with the number you expect.`--dry-run` flag and logs what it would delete before it deletes anything.\nThe 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.\n\nThe 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:\n\n``` js\nconst token = (uid: string) => crypto.createHmac(\"sha256\", process.env.CALLBACK_SECRET!).update(uid).digest(\"hex\");\n\n// when submitting the job\nconst callBackUrl = `https://example.com/api/backend/sunoapi/${uid}?token=${token(uid)}`;\n\n// in the callback handler\nconst a = Buffer.from(searchParams.get(\"token\") || \"\");\nconst b = Buffer.from(token(uid));\nif (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return new Response(\"forbidden\", { status: 403 });\n```\n\nThe 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.\n\nWith `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.\n\nThe page now calls `notFound()` when a locale has no content, and the `<head>` component only emits `hreflang` links for the locales that do:\n\n``` js\nconst content = songFromLyrics[locale];\nif (!content) notFound();\n// …\n<HeadInfo page=\"/song-from-lyrics\" alternateLocales={Object.keys(songFromLyrics)} />\n```\n\nThe sitemap builds its entries from the same object, so the page list, the `hreflang` set and the sitemap can't drift apart.\n\n`UPDATE` against a `count(*)` you predicted beforehand.\nIf you want to see the result, the app is [AISong.Fun](https://aisong.fun/song-from-lyrics). Paste a few lines of lyrics, and it sings them in your language.", "url": "https://wpnews.pro/news/five-production-bugs-from-running-a-20-language-ai-song-generator-on-next-js", "canonical_source": "https://dev.to/anders_lee_28dbcc2fd8e034/five-production-bugs-from-running-a-20-language-ai-song-generator-on-nextjs-3el1", "published_at": "2026-10-25 13:32:11+00:00", "updated_at": "2026-10-25 13:42:43.823192+00:00", "lang": "en", "topics": ["ai-products", "developer-tools", "ai-tools"], "entities": ["Next.js", "Cloudflare R2", "next-intl", "PostgreSQL", "aws4fetch", "axios", "Google Analytics"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/five-production-bugs-from-running-a-20-language-ai-song-generator-on-next-js", "markdown": "https://wpnews.pro/news/five-production-bugs-from-running-a-20-language-ai-song-generator-on-next-js.md", "text": "https://wpnews.pro/news/five-production-bugs-from-running-a-20-language-ai-song-generator-on-next-js.txt", "jsonld": "https://wpnews.pro/news/five-production-bugs-from-running-a-20-language-ai-song-generator-on-next-js.jsonld"}}