A while ago I ran curl against my own portfolio and got back half a kilobyte of HTML. The only visible text in it was the page title: 17 characters. My experience, my projects, the links to my articles, all of it was rendered in the browser by JavaScript.
For visitors, that was fine. For a LinkedIn link preview, an ATS crawler or any bot that doesn't run JavaScript, my portfolio was an empty page called "Mehdi's Portfolio". Every route shared that one title, with no description and no Open Graph tags.
The site was a React 19 SPA built with Vite, routed with react-router and deployed on Vercel, with a rewrite sending every path to index.html. I moved it to the Next.js App Router so every page ships as real HTML. This post is about the parts that turned out less simple than I expected.
One rule: visitors shouldn't notice
I gave myself a single constraint: same content, same design, pixel for pixel. If a migration "mostly looks the same", you stop noticing what broke.
So before touching anything, I wrote down what the old site actually had: two routes (/ and /blog), the shared nav, cursor and footer, seven home sections with their anchors, and three blog cards. I also screenshotted the old build, which became the reference at the end.
The SEO part was a fixed checklist: a title, description and canonical URL per page, Open Graph and Twitter tags, a sitemap, a robots.txt, and a JSON-LD Person schema on the home page.
Seven things that bit me
1. Two stylesheets, and an order nobody chose
The old app had App.css, imported in App.tsx, and style.css, imported in main.tsx after App. Vite emits CSS in import order, so App.css came first. That mattered, because both files styled some of the same selectors:
/* App.css */ .lead { opacity: .7; margin-top: .8rem; } /* style.css */ .lead { opacity: .9; margin: 0; } The hero subtitle I had been looking at for months got its opacity and margin from style.css. I checked the byte offsets in the built CSS to confirm the order, then merged both into one globals.css, in that same order.
2. The theme toggle
The old toggle read localStorage inside a useState initializer. There's no window on the server, and a server-rendered default would flash the wrong theme for anyone on light mode.
The fix has three parts. A tiny blocking script in <head> sets data-theme on <html> before first paint. The button renders both icons and lets CSS hide the wrong one, so it's right before React even hydrates. The parts that must live in React (the aria-label, the click handler) read the theme through useSyncExternalStore, with 'dark' as the server snapshot. No flash, no hydration warning.
3. Counters that pre-rendered as zero
The stats count up from 0 when they scroll into view. Pre-rendered as-is, the HTML would have said 0+ projects and 0M+ users. Not what I wanted a crawler to read.
The counters now start at their final values, so that's what the HTML contains. After hydration, an IntersectionObserver resets a card to 0 only if it's off-screen, then animates it when it comes into view. While porting it, I also noticed the old code called the custom hook inside .map(), which breaks the Rules of Hooks. Moving it into a small StatCard component fixed that.
4. Read on <!-- -->DEV<!-- --> →
My tests couldn't find "Read on DEV →" in the blog page, even though it showed up fine in the browser. In the raw HTML, React had split Read on {post.platform} → into three text nodes separated by comment markers. Anything searching the HTML as plain text misses it. One template literal, {`Read on ${post.platform} →`}, renders a single clean string.
5. openGraph is replaced, not merged
An opengraph-image.tsx at the app root gave the home page its preview image. The blog page defines its own openGraph object, and it ended up with no og:image at all, because Next.js replaces that object per page instead of merging it. Adding opengraph-image.tsx and twitter-image.tsx next to the blog page fixed it, and gave the blog its own card.
6. Vercel looking for dist
The first preview deploy failed with "No Output Directory named 'dist' found". The build itself had worked. The Vercel project was created for the Vite app, and its saved settings still pointed at dist, while Next.js writes to .next.
I reproduced it locally with vercel build and a fake project config, then added a vercel.json setting framework to nextjs and outputDirectory to .next. It travels with the branch, so production keeps building the old site until the merge. Switching the preset in the dashboard first would have broken it. The old catch-all rewrite had to go too. It only existed so client-side routing survived a page refresh. With real routes, it would have answered every unknown URL with the home page and a 200 instead of a 404.
7. A Node version I didn’t choose
"engines": { "node": ">=20.9.0" } looked harmless. Vercel treated it as “latest” and built on Node 26, ignoring the project’s Node 24 setting, while CI ran Node 22. Pinning "24.x" made all three match.
How I checked it
"It looks the same" wasn't good enough, so I made it measurable. Playwright took full-page screenshots of the old build and the new one: two pages, desktop and mobile, light and dark. I disabled animations and gave the counters time to finish so the shots were deterministic. Pillow's ImageChops.difference came back empty for every pair. All 8 were pixel-identical.
A small node:test suite then reads the HTML that next build writes. It checks that every blog card is there, that every external link has target="_blank" and rel="noopener noreferrer", that each page has its own metadata, and that the JSON-LD, sitemap and robots.txt exist. The blog test reads the same typed data file the page renders from, so a new post gets tested without me touching the tests. CI runs lint, build and tests on every push and pull request to main.
Last, I exported the branch with git archive into an empty folder and ran everything from scratch, to be sure nothing depended on files sitting on my machine.
Before and after
| Vite SPA | Next.js | |
|---|---|---|
Raw HTML on / | 0.5 kB, 17 characters of text | 43.9 kB, 1,962 characters of text |
Raw HTML on /blog | 0.5 kB, 17 characters of text | 18.0 kB, 794 characters of text |
| Metadata per page | one shared title | title, description, canonical, OG and Twitter tags |
JavaScript on / (gzip) | 75.6 kB | 137.3 kB |
| CSS (gzip) | 2.2 kB | 2.2 kB |
Production build (npm run build) | about 1.5 s | about 6.5 s |
The number I don't love is the JavaScript: about 1.8 times bigger, because the App Router runtime ships alongside React. The difference is that the content no longer waits for it. It's in the first response, and the JavaScript also handles client-side navigation, not only the toggle and the counters.
What I accepted, and what's next
A few trade-offs stayed in:
- On tall screens where the stats are visible at first paint, you briefly see the final numbers before they reset and count up.
- The footer year is now computed at build time, so it changes on the next deploy, not on January 1st.
- I'm on ESLint 9 even though it's end-of-life, because the plugins behind
eslint-config-nextdon't support 10 yet. - Next on my list: a playful3D version of this portfolio.