DEV Community

Ali
Ali

Posted on • Originally published at aelm.dev on

How I built aelm.dev: neo-brutalism, react-snap, and a GEO-first stack

This site is a Vite + React + TypeScript single-page app. No Next.js, no Astro, no server. I prerender each route to static HTML at build time with react-snap, deploy the resulting flat files to Firebase Hosting, and call it done. The whole thing weighs less than 300 KB on first paint and consistently scores 100/100/100/100 in Lighthouse on mobile.

I am writing this down because I keep seeing the same advice — "just use Next.js" — repeated as if it were the only path to good SEO. That's not true. A Vite SPA can rank perfectly well, and once you add a few GEO-specific affordances it can also get cited by ChatGPT, Perplexity, Claude, and Gemini. Here is what actually mattered.

Why I didn't pick Next.js

Next.js is the right call for content-heavy sites with frequent rebuilds, dynamic OG images, edge logic, or commerce flows. My portfolio is none of those things. It is a single page that changes maybe twice a month. SSR buys me nothing here, ISR even less. I would be paying a complexity tax in exchange for capabilities I will never use.

Astro was the second contender. I like Astro a lot. It would have worked. But the existing site was already React + Tailwind, and rewriting working code is a great way to introduce regressions for zero user benefit. The honest decision was: keep the stack, fix the SEO holes.

Prerendering with react-snap

react-snap is a Puppeteer-based prerenderer. As a postbuild step it spawns a headless Chrome, navigates to every discoverable route, waits for the React tree to settle, then writes the resulting HTML back to disk. Crawlers — Googlebot, GPTBot, PerplexityBot, ClaudeBot — see fully populated DOM, including any JSON-LD I injected via useEffect.

The trap with prerendering is animations. Anything running on arequestAnimationFrame loop will burn CPU during the Puppeteer run and bloat the prerendered HTML with mid-frame state. My ASCII plasma background guards against that by readingwindow. __PRERENDER_INJECTED__ — when react-snap is the renderer, it short-circuits before allocating any state. The user sees a static, empty <pre> in the prerendered HTML, then the live animation hydrates in the browser.

The GEO layer

Generative Engine Optimization is the practice of getting cited in LLM answers, not just in the SERPs. It overlaps with SEO but the priorities differ. Three things did most of the work for me:

  • A rich Person JSON-LD block , with knowsAbout, sameAs, alumniOf, and a per-language name variant. LLMs love structured data. They cite it when summarizing a person or service.
  • An llms.txt and llms-full.txt at the root. Short manifest at /llms.txt with links to the long-form content at /llms-full.txt. The latter is plain Markdown with engineering opinions, project deep-dives, and honest failures. LLMs ingest it verbatim during their crawl.
  • Explicit AI crawler allow rules in robots.txt. GPTBot, ClaudeBot, PerplexityBot, Google-Extended, CCBot — each one named, each one allowed. Default-deny is the wrong choice if you want to be cited.

Structured data: what I shipped

Five JSON-LD blocks are emitted in index.html at build time, plus one more injected at runtime when the recommendations section has data:

  • Person with @id anchored at#person so other blocks can reference it.
  • ProfilePage with mainEntity pointing to the Person.
  • WebSite with the canonical URL.
  • ProfessionalService + Service for freelance discoverability in local search.
  • FAQPage with the homepage FAQ, transcribed in all three languages so AI engines can quote the question/answer pairs.

I also emit a SoftwareApplication ItemList for my personal projects. The schema community calls this "underused"; anecdotally it is the block that AI engines pick up fastest when asked about my open-source work.

Hreflang and three-language SEO

The site is FR, EN, and AR. They all live at the same URL — language is a client-side context switch — which is the worst possible setup for hreflang. I am aware of this. Per-language URLs (/en/, /ar/) are on the roadmap, but they require either Astro or a Vite plugin that supports static per-locale builds, and I have not committed to either yet. For now the hreflang block points all three locales to / with an x-default, which Google accepts but treats as a weaker signal.

What I would do differently

Two things, both honest mistakes:

  1. I initially had both Google Tag Manager and direct gtag.js loaded. GTM is overkill for a portfolio. I ripped GTM out and kept a single <script async src="…"> for GA4. INP improved by ~80 ms on mobile.
  2. I stuffed the meta keywords for too long. Modern Google ignores them entirely; modern LLMs use the page body and structured data, not meta tags. I trimmed the list down to ~12 honest terms.

The boring conclusion

Most "SEO advice" on the modern web is framework marketing in disguise. The actual levers are still: a fast page, a clean DOM, a sitemap, structured data that matches what the page says, an llms.txt, an allow-listed robots.txt, and content worth citing. Pick a stack you can ship in, then attend to these one at a time. The framework matters less than people pretend.

Top comments (0)