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 address decides the language
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. In the first version the server read a cookie and the Accept-Language header. In October I replaced that with a simpler rule: each language has its own address, and the address decides.
export function localeFromParam(param: string | undefined): Locale | null {
if (param === undefined) return DEFAULT_LOCALE
return isLocale(param) && param !== DEFAULT_LOCALE ? param : null
}- No prefix, Portuguese.
/,/sitesand/blogare always served in Portuguese, the default language. - Under
/en, English. The same pages exist at/en,/en/sitesand/en/blog, with the same slugs. The PT/EN switch is an ordinary link to the same page in the other language. - Anything else, 404. A first segment that is not
enis not a language, and the page does not exist.
No cookie and no Accept-Language take part in the decision, so the same address always returns the same language. The first screen, the <html lang> and the meta tags are already right 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.
In the first version the site used a single address for both languages, and that had a cost. Google explains that Googlebot fetches pages without sending Accept-Language and from US IP addresses, and recommends separate URLs per language linked with hreflang. With no cookie and no such header, the search engine always received the English version, and only that one was indexed, while the people looking for me search in Portuguese. That is why I changed it: every page now points to its version in each language with hreflang (pt-PT, en and x-default, the last on the Portuguese address), and the sitemap lists both.
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.