Home
JExcellenceJExcellenceHome
Back to overview
UI / UX

From Vite to Next.js 16 — what actually broke during the rewrite

Three weeks of migration, a new stack, a few good lessons: what produced real friction when moving from Vite + Tailwind to Next.js 16 with Once UI — and what was surprisingly smooth.

May 10, 20269 min readby Justin Eiletz
  • Next.js
  • Migration
  • Once UI
  • Engineering

Until three weeks ago this site ran on a classic Vite stack: React 18, Tailwind CSS, React Router, a heap of hand-rolled components. It worked. But every new section meant two hours of layout maths, and the built-in SEO consisted of a lonely react-helmet wrapper.

Three weeks later it's on Next.js 16 with Once UI 1.7, URL-based i18n via next-intl, and Server Components by default. In this post: what cost more than expected during the migration — and what was so smooth I would have done the stack switch much earlier in hindsight.

Why migrate at all?

Three reasons, hard-prioritised:

  • SEO with substance. Vite-React SPAs render client-side. Google has caught up on JS rendering, but Bing, DuckDuckGo, and a few others less so. Server Components deliver HTML every crawler reads without detour.
  • i18n as a first-class citizen. URL-based localisation (/de, /en) without a custom routing construct was always a hack on React Router. With next-intl it's a single middleware entry.
  • Images, fonts, assets. next/image and next/font handle most of the performance optimisation without me touching a Webpack config.

What actually broke

The honest stumbling blocks, in the order I hit them.

1. Server Components don't auto-detect the "use client" boundary

With useState, useEffect, or an onClick handler, a component automatically lands in the client bundle — provided it itself is marked "use client" or imported from one. Forget the directive and Next.js throws a build error whose stack trace often points at the innermost component, not the place where your boundary should sit.

Lesson: one clear split per section. On this site the server world ends at the <Pricing> wrapper; everything inside is client. That hard line saved migration day.

2. Theme-init scripts and nested layouts

For Once UI to start without a flash of unstyled content (FOUC), a tiny inline script needs to set the right data-theme attribute on <html> before hydration. In my first setup that script lived in [locale]/layout.tsx — right next to <html>.

On locale switch, React printed a warning ("encountered a script tag while rendering a React component"), and Next.js also warned against strategy="beforeInteractive" in nested layouts. The fix: html, body, and the init script belong in the root layout (app/layout.tsx ). The [locale]/layout.tsx becomes a pure content layer and updates the lang attribute via a small client-effect component.

That's one of the lessons that doesn't show up in any blog post but hits every Next.js 16 migrant who supports more than one locale.

3. Tailwind out, SCSS modules in

Once UI makes no assumptions about the styling layer. I could have kept Tailwind — but chose SCSS modules because Tailwind in combination with Once UI's own data-* selectors becomes tedious.

Migration cost: everything. Every single className="flex items-center gap-4" turned into a structured <Row gap="16"> or a local .row class in the section's SCSS module. It cost a full week, but the codebase reads markedly clearer today — markup is content structure again, not styling logic.

4. Once UI spacing tokens are strict

Spacing props only accept discrete steps (0, 1, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 104, 128, 160). A simple gap="6" throws a TypeScript error. Initially annoying, in hindsight correct: it prevents the slow spacing erosion every grown codebase eventually has.

5. Responsive props aren't reactive everywhere

Once UI's hide prop and similar conditional props work — but at breakpoints that didn't always match my expectations (especially for the header between desktop and mobile). Pragmatic fix: I wrote plain CSS media queries for two spots. Faster than reverse-engineering library internals.

6. next-intl + locale routing

The routing migration itself was surprisingly smooth. But: the generateStaticParams requirement for every dynamic locale segment is easy to miss. Without it the site works in dev, but npm run build aborts.

What was surprisingly smooth

The images. next/image moved every single image to AVIF + WebP with no comment. Lighthouse Performance jumped from 84 to 99 — at zero configuration cost.

The fonts. next/font/google ships fonts locally without your browser ever reaching out to Google's servers. That's not just GDPR-friendly — it eliminates the layout-shift problem Vite had with FontFaceObserver.

SEO metadata. Next's metadata API has grown up: generateMetadata reads the locale from the param, generates per-page title + description + OG + hreflang alternates. That's boilerplate you write once and never touch again.

JSON-LD schema. Rendered straight from a server component in the layout. Service, FAQPage, and BreadcrumbList schemas on every audience landing page are three lines of <Script type="application/ld+json"> — invisible at runtime, very visible in the Search Console report.

What I'd do differently

Move html and body into the root layout earlier. I initially placed them in [locale]/layout.tsx because that's the intuitive spot and the next-intl docs example lives there. The mistake cost half a day, because several issues (theme init, JSON-LD script, hydration) were only cleanly solvable with the right structure.

Decouple the header from Once UI's responsive props sooner. I would have saved two days of friction by going to plain media queries from the start — especially for a component as central as the header.

Build the frontmatter-style blog system earlier. I initially considered Markdown posts via remark, then switched to TSX files with a default export — fewer dependencies, more flexibility. The post you're reading is exactly such a file.

Would I do it again?

Without hesitation. Vite + React 18 + Tailwind is an excellent stack for apps that mostly live client-side. For a marketing or portfolio site where SEO, i18n, and maintainable structures matter more than perceived bundle size, Next.js 16 with Once UI is virtually the unrivalled tool.

Three weeks of investment, a stack I want to work with for the next two years, and a site I won't refactor every three months. That's a trade I'd take again happily.

The code for this site is private — but if you're sitting on a similar migration and stuck on one of the points above, write me. Half an hour of pair programming saves the two days I spent finding these stumbles myself.