Skip to main content
NJannasch.Dev

Rebuilding My Portfolio with Astro, Svelte & Tailwind CSS

· 4 min read
AstroSvelteTailwind CSSCloudflare

My portfolio site has been running on Gatsby v2 since 2020. React 16, Tailwind v1, GraphQL data layer — all end-of-life. It still worked, but the developer experience had become painful: slow builds, outdated dependencies, and a stack that felt heavy for what is essentially a static site. Time for a rebuild.

Why Astro?

I evaluated a few options: Next.js, SvelteKit, Hugo, and Astro. The decision came down to what the site actually needs:

  • Static HTML — no server, no API routes, no runtime JavaScript unless explicitly needed
  • Markdown content — blog posts and project descriptions live in markdown files
  • Minimal JS — a portfolio site doesn’t need a full SPA framework shipping to the browser

Astro’s island architecture is perfect for this. The site ships zero JavaScript by default. Interactive components (like the mobile navigation or the experience timeline) only hydrate where needed. Everything else is plain HTML and CSS.

Compared to Gatsby, the difference is dramatic. Gatsby ships React and its runtime to every page, even if nothing is interactive. Astro doesn’t.

The Stack

  • Astro 5 — static site generator with content collections
  • Svelte 5 — interactive components (responsive nav, experience timeline)
  • Tailwind CSS v4 — utility-first styling with CSS-first configuration
  • Cloudflare Pages — hosting and CDN

Content Collections

Astro’s content collections replace Gatsby’s GraphQL data layer. Blog posts and hackathon projects are markdown files with typed frontmatter schemas defined in Zod:

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    description: z.string(),
    tags: z.array(z.string()).optional(),
    draft: z.boolean().optional(),
  }),
});

No GraphQL queries, no page creation API, no gatsby-node.js. Just markdown files in a folder with type-safe schemas. If the frontmatter doesn’t match the schema, the build fails — which is exactly what you want.

Svelte for Interactive Islands

Most of the site is static Astro components (.astro files) — they render at build time and ship no JavaScript. But two components need client-side interactivity:

  1. Header — mobile hamburger menu toggle and dark mode switch
  2. Experience Timeline — alternating layout with responsive behavior

These are Svelte 5 components loaded with Astro’s client:only="svelte" directive, meaning they render entirely on the client. Svelte 5’s runes ($state, $props) make the reactivity model clean and explicit:

<script lang="ts">
  let open = $state(false);
  let dark = $state(false);
</script>

Why Svelte over React? For small interactive islands, Svelte compiles away the framework. The resulting JavaScript is smaller and faster than shipping React’s runtime for a hamburger menu.

Tailwind CSS v4

Tailwind v4 introduced CSS-first configuration. Instead of tailwind.config.js, theme values are defined directly in CSS using @theme:

@theme {
  --color-bg: #fafbfc;
  --color-card: #ffffff;
  --color-accent: #4c6ef5;
  --color-text: #1a1a2e;
}

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-card: #1e293b;
  --color-accent: #818cf8;
  --color-text: #e2e8f0;
}

Dark mode is handled through CSS variables toggled by a data-theme attribute on the <html> element, with the preference persisted in localStorage.

Hosting on Cloudflare Pages

The site is deployed on Cloudflare Pages with automatic deployments from GitHub. The build configuration is straightforward:

  • Build command: npm run build
  • Output directory: dist
  • Node version: 22 (set via NODE_VERSION environment variable)

Every push to main triggers a new deployment. Cloudflare Pages serves the static files from their global CDN with automatic HTTPS. For a static site, it’s effectively free and incredibly fast.

Crawler & AI Policies

I also added explicit crawler policies, which can serve as a low-level security consideration besides tweaking Cloudflare’s built-in security settings:

  • robots.txt — allows search engines (Googlebot, Bingbot) but rejects AI training crawlers (GPTBot, CCBot, ClaudeBot, Google-Extended, and others)
  • llms.txt — signals to LLM agents that site content is not licensed for training or fine-tuning

These are signals, not enforcement — crawlers could ignore them. But they establish clear intent and are respected by most legitimate crawlers.

What I Learned

A few things I picked up during the migration:

  • Astro’s content collections are a massive improvement over Gatsby’s GraphQL approach. Type-safe, fast, and zero boilerplate.
  • Svelte 5 with Astro requires client:only="svelte" instead of client:load to avoid hydration mismatches when the component depends on client-side state (like reading localStorage for the dark mode preference).
  • Tailwind v4’s CSS-first config with @theme and CSS variables makes dark mode implementation cleaner than the old darkMode: 'class' approach.
  • Cloudflare Pages needs trailingSlash: 'always' in the Astro config to avoid redirect loops, since it serves directory/index.html files natively.

Conclusion

The new stack is simpler, faster, and easier to maintain. The build takes under 5 seconds. Pages ship minimal JavaScript. And the content lives in markdown files that I can edit with any text editor.

The entire rebuild — including content migration, dark mode, and deployment — was done in roughly 2 hours with Claude Code. Having a clear picture of the architecture upfront was what made that possible; the tooling accelerates execution, not design.

If you’re running an old Gatsby site and debating whether to migrate — do it. The ecosystem has moved on, and modern tools like Astro make static sites feel effortless again.

The views and opinions expressed here are my own and do not reflect those of my employer.