Why we moved our site from React to Astro
A contact form, bad link previews and a self-hosted server made us switch. Plus the one bug that would have silently broken our form in production.

Our site used to be a React single-page app built with Vite. It worked, and it had nice page transitions. Two problems pushed us to change it, and both came from the same place: there was no server.
Problem 1: the contact form had nowhere to go
We wanted the form to send us an email through Resend. That needs an API key, and an API key can’t live in code that runs in the visitor’s browser, because anyone could open the page, copy it and send email as us.
With a static SPA, the usual answer is a third-party form service. We host our own things on Coolify, though, and we didn’t want another account just to receive a few messages.
Problem 2: the pages were empty until JavaScript ran
An SPA sends an almost empty HTML file and builds the page in the browser. Google copes with that, mostly. Link previews on WhatsApp, LinkedIn or X usually don’t: every page shared the same title and description, because those were only swapped in by JavaScript later.
Why Astro
Astro builds every page to plain HTML ahead of time, and still lets a single route run on a server. So we got:
- Real HTML for every page, each with its own title, description and preview image
- One server endpoint,
/api/contact, that validates the form and talks to Resend - Much less JavaScript. We dropped React, Framer Motion and GSAP. Scroll reveals are now CSS plus a tiny script, and the FAQ is a native
<details>element.
The whole thing runs as one small Docker container on Coolify.
How the form protects itself
The endpoint checks a few things before any email is sent:
- A honeypot field. It’s hidden from people. Bots tend to fill in every field, so if it has a value we quietly drop the message and still reply “thanks”, so the bot doesn’t learn anything.
- A rate limit. At most three messages per IP address every ten minutes.
- Validation. Name, a real-looking email address, one of the listed project types, and a message under 5,000 characters.
- Escaping. Whatever you type is shown in our inbox as plain text, never as HTML.
On top of that, Astro rejects form posts that come from a different website.
The bug that testing caught
That last protection almost broke everything. On Coolify, a proxy (Traefik) handles HTTPS and forwards requests to our app as plain HTTP. So the browser says it’s posting from https://kraistudio.com, while the app thinks it’s running on http://kraistudio.com. Astro saw two different origins and answered every single real submission with 403 Forbidden.
Nothing about it showed up in local development, because there’s no proxy there. We only caught it by simulating the proxy’s headers before deploying. The fix is one setting that tells Astro which domains it can trust those forwarded headers for:
// astro.config.mjs
security: {
allowedDomains: [
{ hostname: 'kraistudio.com', protocol: 'https' },
{ hostname: 'www.kraistudio.com', protocol: 'https' },
],
},
If you’re self-hosting Astro behind a reverse proxy and your forms mysteriously return 403, check this first.

