00// blog

Why I Rebuilt My Portfolio in Astro (And How It's Configured)


For a couple of years my portfolio was a Next.js app. It worked fine, but every time I opened the project to make a small content tweak, I ended up staring at a hydration warning or waiting on a dev server that felt heavier than the actual site deserved. A personal portfolio is mostly static content: text, project cards, a hero section, some animations. It doesn’t need a client-side router, a JS runtime bundled into every page load, or a framework fighting me over what should and shouldn’t be interactive. So I rebuilt it from scratch on Astro, and it’s been one of the better technical decisions I’ve made for this site.

Placeholder: before/after architecture diagram, replace later

Why Astro over Next.js

Three things pushed me over:

  • Islands over full hydration. Astro ships zero JavaScript by default and only hydrates the components that actually need interactivity. My site barely has any client-side state. The only real interactivity is scroll-driven animation, so most of the page never needs to boot a framework runtime at all.
  • Content collections. Astro’s content collections give me typed, schema-validated Markdown/MDX out of the box. No custom MDX pipeline to maintain, no manual frontmatter parsing.
  • It’s just simpler. Routing is file-based, pages are .astro files that look like HTML with a frontmatter script block, and there’s no need for frequent migrations or updates every time Next ships a new paradigm.

None of this is a knock on Next.js. It’s the right tool for an app with real client state and server actions. My portfolio just isn’t that.

How the project is set up

The stack, as it stands today:

  • Astro 7 as the core framework, with the @astrojs/mdx integration for MDX support in blog posts.
  • Tailwind CSS v4, wired in directly through the @tailwindcss/vite plugin rather than a separate Astro integration. v4’s CSS-first config means there’s no tailwind.config.js to maintain.
  • GSAP for animation: a hover highlight that uses GSAP’s Flip plugin to animate project rows, plus a canvas-based “ocean” ripple effect in the hero section.
  • Three.js for supplementary WebGL work.
  • Local variable fonts. Geist is self-hosted as a variable font (Geist[wght].woff2) via fontProviders.local(), and Geist Mono comes from the Fontsource provider, both registered through Astro’s built-in font API instead of a <link> tag to Google Fonts.
  • @astrojs/sitemap for an auto-generated sitemap, and @astrojs/rss for the blog’s RSS feed.
  • Sharp for build-time image optimization via astro:assets.
  • TypeScript throughout, checked with astro check, plus Vitest for unit tests on the pure logic (the ocean-effect math and the llms.txt builder are both tested in isolation from any DOM).
  • Husky + lint-staged + Prettier (with prettier-plugin-astro) so formatting is enforced on commit, not in code review.
  • Deployed on Vercel, configured through vercel.json.
// astro.config.mjs (trimmed)
export default defineConfig({
	site: 'https://rubel-hossain.vercel.app',
	integrations: [mdx(), sitemap()],
	redirects: {
		'/projects': '/works',
		'/projects/[...page]': '/works/[...page]',
	},
	fonts: [
		{
			provider: fontProviders.local(),
			name: 'Geist',
			cssVariable: '--font-geist',
			options: {
				variants: [{ src: ['./src/assets/fonts/geist/Geist[wght].woff2'], weight: '100 900' }],
			},
		},
	],
	vite: { plugins: [tailwindcss()] },
});

Blog content lives in src/content/blog/ and is validated against a Zod schema: title, description, pubDate, an optional updatedDate, and an optional heroImage that Astro resolves and optimizes through its image() helper.

// src/content.config.ts
const blog = defineCollection({
	loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
	schema: ({ image }) =>
		z.object({
			title: z.string(),
			description: z.string(),
			pubDate: z.coerce.date(),
			updatedDate: z.coerce.date().optional(),
			heroImage: z.optional(image()),
		}),
});

If a post is missing a required field or has a malformed date, the build fails loudly at compile time instead of rendering a broken page in production.

Performance benefits

Moving to Astro’s islands architecture had an immediate, measurable effect:

  • Near-zero JavaScript on first load. Pages that don’t need interactivity ship no framework runtime, only the plain HTML/CSS, plus whatever small script is needed for something like the scroll-triggered GSAP animations.
  • Faster Time to Interactive. Because there’s no hydration pass over the whole page, the site is interactive essentially as soon as it’s painted.
  • Smaller build output. Static HTML generation for pages that don’t change per-request means the deployed bundle is a fraction of the size the Next.js version was.
  • Image optimization is automatic. Every image referenced through astro:assets (hero images, inline Markdown images) is resized and re-encoded at build time by Sharp. No manual next/image config, no runtime image server needed on Vercel.
  • Lighthouse-friendly by default. Astro’s baseline output already scores well on Performance, Best Practices, and SEO. I didn’t have to fight the framework to get there, unlike the incremental tuning Next.js sometimes needs for a fully static site.

Placeholder: Lighthouse score screenshot, replace later

How it’s designed

Design-wise, the goal was restraint: a dark, high-contrast layout built around Tailwind v4 utility classes, with animation reserved for a few moments that earn it. A hover-highlight effect on project rows, built with GSAP’s Flip plugin so the highlight glides between rows instead of snapping, and a subtle animated grid/ripple effect behind the hero section rendered on canvas. Everything else (typography, spacing, the blog’s prose styles) stays quiet so the two or three animated details actually stand out.

Structurally, the site follows Astro’s conventional layout:

src/
├── assets/       # fonts, images, blog placeholders
├── components/   # Header, Footer, ProjectCard, SkillsSection, etc.
├── content/      # blog/ collection (Markdown + MDX)
├── layouts/      # Layout.astro, BlogPost.astro
├── lib/          # pure helper functions (llms.txt builder, effect math)
└── pages/        # file-based routes

Keeping DOM-touching code separate from pure logic (in src/lib/) is what makes it possible to unit test things like the ocean-effect math or the llms.txt generator with Vitest, without spinning up a browser.

Making it SEO-friendly

Astro’s starter template already gets you canonical URLs, a sitemap, and RSS support for free, but I layered a few things on top:

  • A centralized src/data/seo.ts config for site URL, keywords, locale, and social handles, so every page pulls from one source of truth instead of hardcoding metadata.
  • A shared BaseHead.astro component that renders canonical links, Open Graph tags, Twitter card tags, and JSON-LD structured data on every page (with < escaped inside the JSON-LD script so it can’t accidentally close the <script> tag early).
  • An explicit robots.txt that allows mainstream crawlers and AI crawlers. GPTBot, ChatGPT-User, ClaudeBot, Claude-User, anthropic-ai, PerplexityBot, Google-Extended, and CCBot are all given Allow: /, since I want the site indexed and citable rather than blocked by default.
  • A generated llms.txt endpoint (src/pages/llms.txt.ts) built from the same portfolio data that powers the UI, so AI assistants summarizing or citing the site get accurate, structured context instead of having to scrape the rendered HTML.

None of this needed a plugin. Astro’s plain file-based routing made it straightforward to add robots.txt and llms.txt as regular pages/endpoints that build alongside everything else.

Was it worth it

Yes. The site is faster, the codebase is smaller, and I spend less time fighting the framework and more time actually shipping content and features. If your project is content-heavy and only sparingly interactive, that’s exactly the shape Astro is built for.