Web engineering
How to run Next.js on Cloudflare Workers with vinext and edge caching
The Next.js 16 code behind amgsoftwarestudio.com is compiled to a Worker with vinext; public HTML is served from a five-minute edge cache tied to the deployment version.
SHORT ANSWER
amgsoftwarestudio.com compiles its Next.js 16 App Router code with vinext and runs it as a single Cloudflare Worker. The Worker keeps public HTML from anonymous GET requests in the Cache API for 300 seconds and adds Cloudflare’s deployment version id to the cache key, so each deployment starts with fresh keys and needs no purge. On 31 October 2026, cached pages returned their first byte in 0.15–0.25 seconds.
In what order does the build run?
Our studio site, amgsoftwarestudio.com, is a bilingual Next.js 16 App Router application built with next-intl. Turkish lives at the root and English under /en. The production build starts with pnpm build:vinext, and prebuild:vinext runs first: build-lastmod.mjs derives sitemap dates from git history, seo:assets renders the share images and images:build prepares responsive images. Then vinext build compiles the app through Vite.
vite.config.ts has two plugins: vinext({ cache: { cdn: cdnAdapter() } }) and @cloudflare/vite-plugin. The Cloudflare plugin declares rsc as the main environment and ssr as its child, so React Server Components and server-side HTML rendering end up in the same Worker bundle. The output is dist/server/wrangler.json plus static files in dist/client. It runs with nodejs_compat, and bindings travel through each request via AsyncLocalStorage.
Publishing is manual on purpose. Since a decision on 7 September 2026, pushing to GitHub does not deploy the site. The GitHub Actions workflow only validates:
- install from the lockfile
pnpm typecheckandpnpm lint- contact form tests
- a production Worker build with
pnpm build:vinext
A release is a build reviewed locally with wrangler dev, then shipped with wrangler deploy --config dist/server/wrangler.json, so CI holds no Cloudflare deployment secret.
What does the Worker do before a request reaches the app?
worker.ts is a thin front layer. It wraps vinext’s fetch-handler and makes these decisions, in order, before the app sees a request:
- Plain-HTTP apex requests,
www.amgsoftwarestudio.comand any*.workers.devhostname get a 308 to the HTTPS apex. /trand/tr/...are treated as a permanent alias and get a 308 to the root, because Turkish is the default locale.- The private analytics panel and
/api/eventsare answered inside the Worker without entering the app. - Everything else goes through the cache check and on to vinext.
Plain HTTP is detected from the x-forwarded-proto and cf-visitor headers. Local wrangler dev sends neither, so development never loops.
HSTS is defined in two layers. next.config.ts adds max-age=63072000; includeSubDomains; preload to every page. The Worker adds max-age=31536000 only when a response lacks the header, which mostly means redirects that never reach the app. When we measured on 31 October 2026, /en returned the two-year header and the www redirect returned the one-year one. A request to http://amgsoftwarestudio.com/en received an HTTP/1.1 308 to https://amgsoftwarestudio.com/en, and /tr/about was sent to /about.
Why is the HTML cache keyed by the deployment version id?
In wrangler.jsonc, a version_metadata binding is declared as CF_VERSION_METADATA. On every request the Worker reads its id and builds the cache key by appending a __studio_version=<id> query parameter to the page URL. A new wrangler deploy means a new version id, so the old HTML becomes unreachable and there is no purge step; old entries simply expire.
A request is cached only when every condition holds at once:
- The method is
GETand the path matches the public-page regex: the root,/about,/process,/services,/work,/apps,/games,/web,/insights,/reviewsand their one-level child pages, with or without the/enprefix. - There is no query string.
- There are no
rsc,next-router-state-treeorauthorizationheaders. - The
acceptheader includestext/html.
The contact page is not on the list, so forms and query variants always stay dynamic. Only responses with status 200 and a text/html content type are written. Before writing, set-cookie is removed: the locale comes from an explicit URL, so public HTML has no use for a preference cookie. The edge copy carries Cache-Control: public, max-age=300, while the copy sent to the browser is no-store. The write runs in ctx.waitUntil, and every response is tagged X-Studio-Cache: HIT or MISS.
How much does the cache change on the live site?
On 31 October 2026 we sent GET-only curl requests to the live site. Responses came from Cloudflare’s Sofia (SOF) data centre. These are small samples from one location.
/en/services: twoMISSrequests with time to first byte of 0.46 and 0.54 seconds, then aHITat 0.15 seconds./enand the root page: three consecutiveHITs each, first byte in 0.16–0.25 seconds./en/about: the first request was aMISS, the next three wereHITs.- The
/enHTML is 559,887 bytes uncompressed and about 71 KB with Brotli.
The HTML is that large because of experimental.inlineCss. CSS arrives inside the HTML rather than as a separate file, which removes the render-blocking stylesheet request but gives up independent CSS caching.
Paths that must stay uncached behaved as expected. /en/contact and /en?x=1 carried no X-Studio-Cache header and returned no-store, must-revalidate. A request with RSC: 1 got a 307. At the start of the run we also saw two consecutive MISSes for /en; three requests a few minutes later were HITs. The cache fills per data centre, so verify with several repeats, not one request.
How is the analytics endpoint protected against abuse?
Instead of a third-party tracking script, the site uses its own event endpoint. A request to /api/events is filtered inside the Worker in this order:
- Any method other than
POSTgets 405 (a live GET confirmed it). - If the
originheader is not the site’s own origin, 403. - A body over 2,048 bytes gets 413; both the
content-lengthheader and the body actually read are checked. - If the event name is not in the 17-item
clientEventslist, or the path fails thecleanPathregex, 400. - Browsers sending
DNT: 1, user agents containing bot, crawler, spider or headless, and signed-in admin visits get 204 but nothing is stored. - If the
ANALYTICS_LIMITERrate limiter refuses, 429.
The limit in wrangler.jsonc is 60 requests per 60 seconds, keyed by event: plus the client IP. The contact form and admin login have their own limiters, both at 5 requests per 60 seconds. If the limiter binding is missing the code returns 429, so it fails closed.
Country comes from Cloudflare’s request.cf.country, not from the client payload. The referrer is kept as a hostname only, and UTM tags are capped at 64 lowercase characters. D1 stores only aggregate daily counters; no visitor ids, raw IPs, form text or query strings.
How are images responsive without a paid transform service?
Images are prepared at build time instead of relying on Next.js image optimisation on Workers. scripts/build-responsive-images.mjs takes every WebP, PNG and JPEG under public/media and uses sharp to produce seven widths: 160, 320, 480, 640, 960, 1280 and 1920 pixels. Output is WebP at quality 80 with effort: 4. No image is upscaled beyond its source (withoutEnlargement). To keep memory use predictable, the work runs through a pool of 4 concurrent workers.
The repo has 269 source images, which at 7 sizes yields 1,883 files and about 51 MB of output. lib/image-loader.ts, registered in next.config.ts as a custom loader, picks the first prebuilt width that is equal to or larger than the requested one and returns /media/responsive/.../<width>.webp. The StudioImage component writes a seven-size srcSet itself and calls react-dom preload for priority images, so markup is identical on the Next.js dev server and in the Worker.
Share images follow the same idea. scripts/build-seo-assets.ts builds a 1200×630 SVG template, wraps the title into at most four lines, composites product screens on top and writes a PNG. Per locale there is one studio image, 56 projects and 8 services: 2 × 65 = 130 images, about 17 MB. Nothing depends on paid Cloudflare Images.
How are llms.txt, sitemap dates and robots.txt generated?
app/llms.txt/route.ts is a route handler marked force-static. It lists services, studio pages, notes and indexable projects as plain text from the same content sources as the pages; AI bots get no separate content. It responds with Cache-Control: public, max-age=3600. On 31 October 2026 the file was 13,119 bytes, 79 lines and 66 link lines.
The sitemap’s <lastmod> values are produced at build time with git log -1 --format=%cI. build-lastmod.mjs splits content files into seven groups: games, apps, web, notes, services, legal and studio. Each group’s date is the date of the last commit that touched its files. If git is unavailable it falls back to the previous JSON value, then the current time. The script comment gives the reason: writing “now” on every build sends search engines a wrong signal.
The approach has a cost. Because project groups include public/media/projects, changing one project image updates the date of every project of that type. On 31 October 2026, 132 of the 154 URLs in the live sitemap carried the same day’s date; notes were dated 8 September and legal pages 2 October.
robots.ts names 17 crawlers, including search, AI search and training bots, and allows all of them. Since a named group replaces * for that bot, the private paths are repeated in each group.
What did the Lighthouse runs show?
The repo keeps docs/seo-geo-evidence/lighthouse-2026-09-07.json, a Lighthouse 13.4.1 report for /en with mobile simulation. Results: performance 76, accessibility 100, best practices 100, SEO 100. FCP 3.2 s, LCP 4.4 s, total blocking time 10 ms, CLS 0, server response about 60 ms, total transfer 967 KiB. The launch handoff notes that FCP and LCP on slow mobile connections still need work.
The later growth-pass notes record a sequence of runs. A fresh mobile baseline gave performance 61 and LCP 8.7 s, with the autoplaying showcase video as the LCP element. On mobile, a 480, 768 and 1080 pixel poster replaced the video, which now loads only on an explicit play. Compact card data that serialises only the displayed fields cut first-load transfer from 1,138,831 bytes to about 689,000, and HTML from 69,636 to 36,521 bytes. Two cold runs still scored 59: the render-blocking stylesheet was the dominant delay.
The last step was inlineCss. The final documented live mobile run: performance 93, FCP 1.7 s, LCP 2.9 s, TBT 100 ms, CLS 0, transfer 713,052 bytes. The note says 2.9 s is still above the 2.5 s good threshold and that these are lab results, not field data. The raw file for that final report is not in the repo.
A checklist if you are building something similar
These are the decisions that worked on amgsoftwarestudio.com when running a Next.js app on Cloudflare Workers:
- Resolve redirects and small API endpoints in the Worker entry file, before the app. Use 308, and treat locale aliases as permanent redirects too.
- Put the deployment version id into the HTML cache key. The purge step disappears and old releases expire on their own.
- Define what gets cached with an explicit allowlist: GET only, no query, no RSC or auth headers, HTML with status 200.
- Strip
set-cookiebefore writing to the cache and sendno-storeto the browser; edge and browser lifetimes can differ. - Tag responses
HITorMISSsocurlcan verify live behaviour. - On the analytics endpoint, check origin, size and an event allowlist, rate-limit per IP, and fail closed if the limiter is missing.
- Generate images at fixed widths during the build and map them with a custom loader.
- Take sitemap dates from git history, but keep the groups narrow.
- Use CI for validation and publish a reviewed build.
- Keep Lighthouse results with their date, page and conditions, including the bad ones.
AMG Software Studio builds fast, multilingual company sites and web products like this on Cloudflare and Next.js.
APPLY IT TO YOUR PRODUCT
Web Development
Corporate sites, product sites and SaaS panels with Next.js. Speed and search visibility planned from the start.