10 May 2026

Why I migrated my personal site from Next.js to Astro

It used to be a Next.js 14 project with Prisma, NextAuth and roughly 30 dependencies I no longer needed. Now it's Astro 6 with about 12 dependencies and noticeably faster builds. This is the "why" and the "how".

The starting point

The Next.js version started as a personal blog and accreted features over time:

  • A Notion-driven notes section
  • A CV page also pulled from Notion
  • A Spotify "now playing" widget in the footer
  • An AI notebook with Auth0/NextAuth, Prisma, LangChain, OpenAI — for a feature I'd long stopped using
  • Dependabot PRs queued up over two years of inactivity

The site hadn't been touched since June 2024 when I came back to it. The first instinct was "modernize the dependencies." But after looking at what was actually in the bundle, the real question was: do I need a JavaScript framework that ships a runtime to the browser to render… mostly text?

The answer was no. So Next.js wasn't the right fit anymore.

Why Astro specifically

Two things drew me to Astro for this:

Static-first, hydration-optional. Astro renders to HTML by default. No JavaScript ships unless I explicitly ask for an "island" of interactivity. For a content site that's mostly notes, the whole page can be 0 KB of JS and still work. Compare that to Next where even a static-feeling page typically ships React + the route's bundle.

Server endpoints when you need them. I still wanted dynamic bits — a Last.fm now-playing pill, a private /me dashboard, a redirect for a Telegram invite. Astro pages and API routes can opt into server rendering with one line: export const prerender = false;. The rest of the site stays static.

I considered Hugo and Eleventy too. Both ship less JS than Astro. But I wanted JSX-like component composition, TypeScript everywhere, and the ability to drop in React/Svelte islands later without rewriting infrastructure. Astro gave me all three.

The migration process

I did it as a single big-bang rewrite rather than incremental migration, because the old codebase had so much to delete. The overall flow:

  1. Audit. Read every page, every API route, every component. Decide what stays and what goes. AI notebook? Gone. Spotify? Replaced with Last.fm later (Spotify's currently-playing endpoint now requires a Premium subscription on the dev account, which made it a bad bet for a free personal feature).
  2. Scaffold Astro fresh in the same repo. No astro create — I wrote the config and structure by hand to keep it minimal. output: "static", Tailwind 4 via the official Vite plugin (the @astrojs/tailwind integration is being deprecated), Vercel adapter.
  3. Port the data layer first. Notion was the one external dependency I wanted to keep — it's still the best lightweight CMS for someone who already lives in Notion. The lib is small:
import { Client } from "@notionhq/client";
import { NotionToMarkdown } from "notion-to-md";

export const notion = new Client({ auth: import.meta.env.NOTION_TOKEN });
export const n2m = new NotionToMarkdown({ notionClient: notion });
  1. Port the design. This was where I spent the most time, because I used the migration as an excuse to tighten the design language. Out: yellow-gradient on every heading. In: editorial serif (Fraunces) for headings, Inter for body, JetBrains Mono for code and labels. The yellow stays as a true accent color — logo, active nav, hover states. The whole design system is about 20 lines of CSS tokens in @theme (Tailwind 4's CSS-first config).
  2. Add the missing pieces. Tag filtering with dedicated pages per tag for SEO. Class-based dark mode with FOUC-safe init script. View Transitions for smooth page navigation. Article hero images with a fixed-position parallax effect. A Last.fm-driven now-playing pill in the top status bar. A private /me dashboard for biometric data, gated by basic-auth middleware.

The thing I almost lost: ISR

Here's the trap. The old Next.js setup had this in every page that loaded Notion content:

export async function getStaticProps() {
  const data = await notion.databases.query({ /* ... */ });
  return { props: { data }, revalidate: 10 };
}

That revalidate: 10 is incremental static regeneration — pages serve from cache, but re-render in the background after 10 seconds. New notes appear without a redeploy.

Astro doesn't have ISR baked into the framework. After my initial migration, I was stuck with classic static-site behavior: publish a note in Notion → nothing happens until the next deploy. For a content-heavy personal site, that's a regression.

The fix turned out to be one config block. The Vercel adapter supports ISR directly:

// astro.config.mjs
adapter: vercel({
  isr: { expiration: 60 },
})

Combined with export const prerender = false; on each Notion-driven page, Vercel deploys those pages as ISR functions. First request after the 60-second cache window triggers a re-render in the background; everyone else gets the cached HTML instantly. Same behavior as Next's revalidate.

A note on dynamic routes specifically: in static mode, getStaticPaths() enumerates which slugs to prerender. With ISR, you drop getStaticPaths() entirely — the page accepts any slug at request time, queries Notion, and returns a 404 if it doesn't exist. That's exactly what I wanted: publish a new note, hit /note/new-slug, the page exists.

I also moved my dynamic sitemap.xml and llms.txt (more on that one below) to ISR endpoints, so they include new notes within the same 60-second window.

View Transitions, finally usable

I'd been avoiding fancy page transitions on previous personal sites because the JavaScript cost was always more than the visual payoff. The native cross-document View Transitions API changed that. It's a CSS rule:

@view-transition {
  navigation: auto;
}

Browsers that support it (Chrome 126+, Safari 18+) cross-fade between pages automatically. Browsers that don't get a normal page navigation. Zero JavaScript, no library, graceful degradation.

For the homepage → note page transition, I added a shared view-transition-name to the note title element on both pages:

<!-- On the card -->
<Heading transitionName={`note-title-${slug}`}>{title}</Heading>

<!-- On the article header -->
<Heading transitionName={`note-title-${slug}`}>{title}</Heading>

The browser sees the same name on both elements, takes a snapshot before navigation, and morphs one into the other after. The note title literally flies from its position in the card grid to its position at the top of the article. It looks like an SPA, but the page is a fresh document load.

What Astro unlocks for me going forward

The thing I keep coming back to: this stack is expandable. A few directions I now have a clean path toward:

  • Demo projects with hidden URLs. I can add a route at /demos/whatever-im-trying and embed full interactive React/Svelte/Vue components as Astro islands. The rest of the site stays 0 KB JS. If a demo gets heavy enough to deserve its own deployment, I move it to a subdomain with whatever stack fits — no need to bend the personal site around it.
  • More fine-grained server endpoints. Anything that needs a secret or runtime data — webhook receivers, a personal API, a small auth-gated tool — drops into src/pages/api/ and gets deployed as a Vercel function. The same pattern I used for Last.fm now-playing and the Oura biometric endpoint.
  • Easy LLM optimization. I added llms.txt as a server-rendered endpoint that lists every published note with title, description, and date. Robots.txt explicitly allows GPTBot, ClaudeBot, PerplexityBot, etc. Combined with proper JSON-LD Article schema and OG tags, the site is set up to be discovered and cited by both search engines and LLMs without ongoing maintenance.
  • Edge-first private dashboards. It's a starting point for building tools that are mine alone — dashboards, automation triggers, whatever — without spinning up a separate app.
  • MDX whenever I want. Most of my writing lives in Notion right now, but if I ever want a piece that's heavy on interactive embeds (a calculator, a chart, an annotated code walkthrough), MDX is one integration away.

Closing thoughts

The point is that a personal site is not an app. It's a publication. Treating it like an app — with framework runtime, server components, hydration boundaries, middleware chains — is solving the wrong problem.

Astro fits the actual problem here: render content to HTML, ship as little JavaScript as possible, escape into server-rendered routes only where I genuinely need them. Two years from now when I come back to it, I want to find a codebase that's still fast, still small, and still legible. Less code is the easiest way to keep that promise.

If you're considering the same migration: start by deleting whatever you can. The migration is mostly subtraction.