Skip to content
Back to posts

How I built vivesimple.me

The stack behind a content site I build and maintain: Astro, React islands, search with no backend, and one small database. What I chose, why, and what I would change.

Elvis Finol 7 min read

Web

Vive Simple is a Spanish-language site my partner and I run for people moving to Spain. It has a blog of long practical guides, a few service pages, a booking page, and testimonials. I’m a cloud security engineer by day, so this site is where I get to do front-end work.

This post is a tour of what it runs on: the choices, the reasons, and a few things I got wrong. If you’re about to build a site for your own project, some of it should save you time.

The stack

That’s a lot of logos for a site that is mostly articles. Most of them do one small job. The short version is: static pages, a few interactive components, one managed database, and one scheduled job.

Why Astro

The site is content. Someone searches for how a residence permit works, lands on a guide, reads it, and maybe books a call. Nothing on that path needs a server rendering pages on request, and almost nothing needs JavaScript.

Astro fits that exactly. It builds every page to static HTML and ships zero JavaScript unless you ask for it. That gives me fast pages, hosting that costs close to nothing, and no application server to patch or defend. For someone who spends the working day on security, having less to attack is a feature.

Islands, and when they wake up

The interactive parts are React components, loaded as what Astro calls islands. The page is static HTML, and only those components hydrate. What I like most is that you choose when each one does:

Three components, three moments
<!-- Needed eventually, on every page: wait until the browser is idle -->
<SearchCommandPalette client:idle />
<!-- At the bottom of an article: wait until it scrolls into view -->
<ArticleFeedback client:visible slug={post.slug} />
<!-- Behind a login, no point rendering it at build time -->
<FeedbackPanel client:only="react" articles={articles} />

For a long time I had everything on client:load, because it’s the option that always works. Moving each component to idle or visible was one of the cheapest performance wins on the whole site.

Worth borrowing

Default every interactive component to client:visible, and only load it earlier when you can name the reason.

Content

Articles are MDX files in the repository, so writing is a commit and there’s no CMS database to run. MDX lets a guide use components such as tabs, accordions and notices where plain Markdown isn’t enough.

The frontmatter is validated with a Zod schema at build time. A mistyped date fails the build and never becomes a broken page in production. It’s a few lines of schema and it has caught me more than once.

Search with no backend

The site has a ⌘K command palette that searches articles, pages and services. There’s no search service behind it. The build emits a static JSON index, the browser fetches it the first time the palette opens, and Fuse.js does the fuzzy matching on the client.

My first version passed the index to the component as props, which meant every page carried the whole thing whether or not anyone searched. Fetching it on demand fixed that.

The part that took real tuning was the language. Matching ignores accents, because nobody types them on a phone. And fuzzy matching breaks on short words: a lot of immigration vocabulary is three-letter acronyms, and searching “NIE” was returning an article about job entrevistas. Short queries now match exactly.

Worth borrowing

A site with a few hundred pages doesn’t need Algolia. A static index and a client-side library are enough, as long as you test with the words your visitors really type.

Images

This is where I was wrong the longest.

Post images are referenced by plain paths, so nothing was optimizing them. The logo was a file large enough to print on a poster, displayed at the size of a postage stamp, on every page. Phones were also downloading a hero image that the mobile layout hides, because display: none doesn’t stop the browser from fetching it.

The fix is a small script using sharp that runs before each build. It writes every image in a few sizes, and the templates output a srcset so the browser picks the right one. The deploy went to roughly a quarter of its previous size, with no visible change.

The lesson

The site always feels fast on your own machine. Look at what the build sends to a phone on mobile data before you trust that.

The one feature with a database

Each article ends with a small widget: was this useful, yes or no, plus an optional comment. It replaced a like button, which felt nice and told me nothing. A “no” with one sentence attached tells me which guide is confusing and why.

It’s the only part of the site that writes data. It uses Supabase, and the browser talks to Postgres directly, with no API of mine in between. That puts the public key in the page source, so the design assumes anyone can call the database:

  • Row Level Security is on, and anonymous users have no access to the table at all.
  • Reads and writes go through two Postgres functions that validate input and return only what a visitor should see.
  • One vote per reader per article is a unique constraint, so the rule can’t be bypassed from the client.
  • There are no accounts. The reader ID is a random value in localStorage, created only when someone votes.
The idea in one line

If the browser talks to your database, the database has to be the thing that says no.

Many of our readers are in the EU, so anything that sets a cookie waits for consent. The tracking scripts aren’t in the HTML at all. They’re injected only after the visitor accepts that category.

For a baseline that doesn’t depend on who clicked “accept”, I use Umami. It’s cookieless, stores nothing on the device, and tells me which guides get read.

Hosting and automation

Netlify builds and publishes the site on every push to main. The same config file sets the security headers, including a Content Security Policy, and the cache rules.

The testimonials are Google reviews. Calling the Places API from the page would expose a key and make page load depend on Google, so the page never calls it. A GitHub Action does, once a week:

.github/workflows/update-reviews.yml
on:
schedule:
- cron: '0 3 * * 1'
workflow_dispatch:

It fetches the reviews, commits them as a JSON file, and that commit triggers a normal build. The key stays in GitHub’s secrets and visitors get static HTML.

Put together, everything on the site happens at one of three moments:

WhenWhat runsExamples
Build timeAstro, the image scriptPages, the search index, image sizes
In the browserIslands, hydrated when neededSearch, the feedback widget
On a scheduleA GitHub ActionGoogle reviews
Worth borrowing

If data changes weekly, it doesn’t need a live integration. Fetch it on a schedule, commit it, and serve it as static content.

What I’d do differently

Use the framework’s image pipeline from day one. My script works, but it exists because of an early shortcut.

Pick one styling system. I ended up with Tailwind utilities, CSS variables and Sass partials. They all work and they all overlap.

Add third parties slowly. Booking, newsletter, analytics: each one is another entry in the CSP, another consent category and another line in the privacy policy.

Measure sooner. Nearly everything I improved, I found by looking at the built output. I waited too long to look.

Final thoughts

I didn’t plan most of this. The site started as a theme I liked, and it grew whenever something annoyed me enough to fix it. Some weeks that was a feature. A lot of weeks it was undoing something I’d done in a hurry.

If I had to start again I’d keep the same stack. It’s cheap to run, it’s fast, and I’m not on call for it, which is what I want from a side project. I’d just look at what I was shipping a lot earlier.

If you’re building something similar and want to compare notes, write to me. I’m always happy to talk about this stuff.