The 3D island already worked. What I wanted next was a blog, a services page, and a proper address for each page in English and German.
On 6 July 2026, I moved this portfolio from a Vite single-page app to the Next.js App Router. The useful part of that migration was deciding which work belonged on the server and which still needed the browser.
Updated 2 October 2026: This article describes the original Next.js 14 migration. The site moved to Next.js 15, React 19 and React Three Fiber 9 in September. I have updated the explanations below to distinguish that first step from the current stack.
Give every page its own address
The Vite version used React Router and i18next, with the language choice stored in localStorage. Routes shared an HTML shell and default metadata; their content appeared after JavaScript ran. English and German did not have their own URL trees.
That made publishing more awkward than it needed to be. I wanted a link to a German article to reliably open that German article, with its title, description and content already in the HTML response.
The App Router gave me that structure: /de/... and /en/..., metadata for each page, and hreflang links connecting translations. Blog posts live in MDX files and render into HTML during the build.
Google can render JavaScript, so the old site was not automatically invisible to search. The improvement is that reading the page no longer depends on that extra step. Other crawlers may not execute JavaScript at all. Google's JavaScript SEO guide explains this distinction.
Separate the framework move from the 3D upgrade
The scene used React Three Fiber 8 and React 18. Those versions belong together: R3F's documentation pairs version 8 with React 18 and version 9 with React 19.
I kept the existing pair for the initial move to Next.js 14. That reduced the number of changes I had to investigate if the scene broke. The React 19 and R3F 9 upgrade came later, alongside Next.js 15.
For a migration like this, that is the decision I would repeat: keep a working renderer stable while moving the application around it.
Three details that needed attention
Browser-only values. The old scene read window.innerWidth during rendering to choose its scale. A server has no window. I moved that measurement into an effect and kept the interactive scene in a client component behind a server-rendered page.
Model URLs. The Vite setup imported .glb files as URLs. In the Next.js setup, I moved the models to public/3d/ and referenced their public paths. This avoided adding a custom asset-loader configuration. The site also sets immutable cache headers for those files, which means a changed model needs a new filename to avoid stale copies.
Language switching. With next-intl, changing language now changes the locale in the URL while keeping the page path. The middleware remembers the preference in a cookie. The URL itself tells a reader which language they are opening.
What I gained
The migration kept the interactive portfolio and added a straightforward publishing workflow around it. Articles and service descriptions arrive as readable HTML, each translation has an address, and the 3D experience runs where it belongs: in the browser.
Vite could also support a blog with the right setup. For this site, Next.js brought routing, content rendering and metadata into one structure I could maintain. That was enough reason to move.
