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 theenvobject 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-IPheader, 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:
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-Languageheader 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.