All postsPublished

How I rebuilt my site on Cloudflare Workers, in two languages

From Next.js on Vercel to TanStack Start on Cloudflare Workers: server-side language detection, one source of content, and the trade-offs.

On Monday I moved my site to a new home and a new shape. It was built with Next.js and hosted on Vercel; it is now a TanStack Start app running on Cloudflare Workers, on a new domain, diogoduarte.net, and in both Portuguese and English. This is not a post against Vercel: the previous version worked. It is a record of how the new one is put together, the two or three things that caught me out, and what it means for anyone who asks me for a website.

What changed, in one day

There were four changes in a row, each in its own commit, so any one of them could be reverted on its own:

  • Framework and hosting: from Next.js on Vercel to TanStack Start (on Vite) on Cloudflare Workers, with the contact form turned into one of Start's own server routes.
  • Package manager: from pnpm to bun. I re-ran the tests, the build and a real form submission before calling that step done.
  • Two languages: every visible string now exists in PT and EN, including the form's error messages.
  • Copy pass: a pass just to rewrite Portuguese that read like a literal translation and to settle the form of address, which kept switching between the informal and the formal "you".

A site rendered in a Worker

Every page is rendered on the server, on every request, inside a Worker. A Worker is not a server left running and waiting: it is code that Cloudflare runs on its network when a request arrives. It runs in isolates, lightweight contexts that, according to Cloudflare's own documentation, start far faster than a Node process in a container. In practice there is no slow first request after the site has been idle.

"Edge" is the word for this, and it is worth being honest about what it means for a visitor in Portugal. Cloudflare has a data centre in Lisbon, and the HTML is produced close to whoever asks for it instead of crossing half the world. But on a page like mine, what matters most for speed is the weight of the page itself: fonts, images, JavaScript. Hosting helps; it does not rescue a heavy page.

Two things that caught me out

  • Keys do not come from process.env. The code inherited from Next.js read the email API key from environment variables, as on a Node server. On Workers, keys and settings live on the env object the Worker receives, and that is where they are read from now. It came up while re-testing a real form submission.
  • The rate limit lives in memory. The form has a per-IP limiter to stop abuse. Because a Worker's memory is not shared between instances, the limit is per instance, not global. For a contact form that is enough, and a comment in the code says so. For anything with money at stake it would have to move to shared storage. The IP comes from the CF-Connecting-IP header, which Cloudflare itself sets.

The language is chosen on the server

The classic bilingual-site mistake is to show the page in English for half a second and then switch to Portuguese once the JavaScript loads. To avoid that, the language is chosen on the server, before any HTML is produced:

lib/i18n/server.ts
export const getInitialLocale = createServerFn({ method: "GET" }).handler(() => {
  const cookie = getCookie("locale")
  if (isLocale(cookie)) return cookie

  return detectLocaleFromHeader(getRequestHeader("accept-language") ?? "en")
})
  • First, the cookie. If the visitor has already pressed the PT/EN switch, that choice wins and is kept for a year.
  • Then, the browser. The Accept-Language header says which languages the visitor prefers and how strongly. I honour the weights, so the preferred language wins rather than whichever is listed first.
  • Finally, English. If neither answers, the page is served in English.

The result travels to the browser inside the page state, so the first screen, the <html lang> and the meta tags are already in the right language and nothing visibly switches.

One source of truth for content

All the content lives in a single file, and anything that does not depend on language is stored there once: company names, links, technologies, dates. Only the prose that really differs between languages has a PT and an EN version. That way a wrong link or a misspelt technology cannot exist in one language and not the other. When I fix something, I fix it once.

The site also uses a single address for both languages. It is simple, but it has a cost I would rather state up front. Google explains that Googlebot fetches pages without sending Accept-Language and from US IP addresses, and recommends separate URLs per language linked with hreflang. In other words, the search engine mostly sees the English version. For a single presentation page I accept that for now. If the site grows pages that need to rank in Portuguese searches, that is the next step.

What a small business gets from this

I do not build a restaurant's site the same way as my own, but the benefits I was after here are the ones I look for on a client's behalf:

  • Speed without server upkeep. There is no machine to patch or restart.
  • Low hosting cost. The Workers free plan includes 100,000 requests a day, and the paid plan starts at US$5 a month. A local business site rarely leaves the free plan.
  • No plugins to update. The contact form is my own code, validated on the client and the server with the same rules, not an extension that needs security updates every month.
  • Two languages done properly. Useful for anyone with tourists or foreign customers, as long as the Portuguese is written as Portuguese and not translated in a hurry.

It is not the answer to everything. If the owner wants to change text every day without calling me, a site like this needs an editing layer on top, or a different tool altogether. But for a site that has to load quickly, be correct and cost little to run, it is the route I choose today.

If you want a site like this for your business, or are thinking of moving the one you have, you can get in touch.

Need a site your team can update without calling a developer?

Start a project