Engineering
We Rebuilt Our Agency Site on Next.js 16 — Here's Every Way It Broke in Production
What this covers: We migrated our own agency site from static HTML to Next.js 16 (App Router, static export) and hit four real production bugs: a Windows-only path bug in the export step, an Apache rewrite rule that worked until routes got one segment deeper, the redirect loop that broke in production because of it, and a scroll animation that quietly cost us 4.8 seconds of mobile LCP. Every fix and number below is from this project’s actual history, not a hypothetical.
Key takeaways
- A same-named folder-plus-file export pattern (
about.html+about/) is normal for Next.js static export, but a Windows path-separator bug can misplace the segment files inside it. - Rewrite rules written to match “every route so far” silently stop working the moment a route gets one segment deeper than what you tested.
- A redirect loop found in production is diagnosable in minutes with
curl -ILand a browser console — don’t guess, trace the actual redirect chain. - Scroll-reveal animations applied to above-the-fold content directly inflate LCP under mobile throttling, even when the image itself is well optimised.
- None of these four bugs showed up in local dev — each needed a live check against the actual deployed output.
Why we rewrote it
We’re a small web and mobile dev shop. Our own marketing site had been a hand-written static HTML site for a while: 17 pages, a shared header and footer copy-pasted into every one of them, and the predictable outcome, a footer link change meant editing 17 files and hoping you didn’t miss one. So we rewrote it on Next.js 16 (App Router, TypeScript, Turbopack, static export) and pointed the same domain at it. This isn’t a “why we chose Next.js” post. It’s the list of things that actually broke, in the order we hit them, with the actual fixes.
Bug 1: the export writes two things with the same name, and one of them is a trap
Next’s static export writes about.html for a page and a same-named about/ directory next to it, holding the route’s segment-prefetch data (__next.*.txtfiles used for client-side navigation). That’s normal and documented. What isn’t well documented: on a Windows build machine, Next 16’s export step has a path-separator bug that nests those segment files one level deeper than it should, backslashes leaking into what should be forward-slash web paths. The result was 95 silent 404s on prefetch requests after deploy, invisible unless you actually go looking at network requests instead of just eyeballing pages.
The fix was a five-line postbuild script that walks out/ and flattens the misplaced files back to where the client actually requests them from. Cheap fix, expensive to find, we only caught it because we diffed every URL in the deployed out/ folder against what a browser actually requested, not because anything looked visibly wrong.
Bug 2: teaching Apache to serve .html files without the extension
A static export gives you contact.html, not /contact. Getting clean URLs on plain Apache (no Node runtime, no reverse proxy, just .htaccess) means three rules fighting each other if you’re not careful:
- Turn off
DirectorySlash, because Apache’s default behaviour is to see theabout/directory sitting next toabout.htmland 301 you toabout/, which has no index file, so you’d land on a 403. - Internally rewrite extensionless requests to their
.htmlfile, but only when that file actually exists, so you don’t swallow real 404s. - Strip trailing slashes on the way in, so
/about/and/aboutdon’t count as two different URLs for SEO purposes.
We got this working for every single-segment route (/about, /service-web-development, /insights) and shipped it. That regex is the whole reason for bug 3.
Bug 3: the redirect loop we found minutes after deploying
A few weeks later we added an /insights section with guide URLs one level deeper, /insights/react-vs-nextjs. Deployed, cleared cache, opened the page to check it. Browser console: ERR_TOO_MANY_REDIRECTS. curl -ILon the URL showed exactly what you’d expect from that error, it bounced between the slash and non-slash version of the URL forever, live, in production.
The cause was almost embarrassing in hindsight: the “rewrite extensionless URLs to .html” rule from bug 2 used the pattern ^([^/.]+)/?$, deliberately excluding slashes, because at the time every route was one segment deep. A two-segment route like insights/react-vs-nextjsnever matched that rule at all, fell through to Apache’s default directory handling, and got caught in the slash-adding/slash-stripping tug-of-war between our own rule and Apache’s defaults.
The fix was one character: ^([^/.]+)/?$ to ^([^.]+)/?$. Drop the slash from the exclusion, keep the dot exclusion so real asset requests with extensions don’t get swept in, and let the existing “does the .htmlfile actually exist” check do the real gatekeeping. Redeployed just the .htaccess file, no need to rebuild the whole site for a one-line config fix, and confirmed every route resolved cleanly.
The lesson that generalises: a rewrite rule that works for every route you’ve shipped so far isn’t the same as a rewrite rule that’s actually correct. Test the assumption (“routes are always one segment”), not just the routes you have today.
Bug 4: a 6.1-second mobile LCP that had nothing to do with the image
Running a real PageSpeed Insights check against the live homepage: desktop scored 93 with a 1.6s LCP. Mobile scored 76, with a 6.1 second LCP. Same page, same hero image, same everything except Lighthouse’s mobile throttling profile (4x CPU slowdown, simulated slow 4G).
The hero image itself was reasonably sized (about 60KB for the mobile variant, proper srcset, fetchPriority="high", the works) so the byte count wasn’t the story. The actual cause was a sitewide scroll-reveal animation system: elements start at opacity: 0 and only fade in once an IntersectionObserver, wired up in a client component, fires after hydration. That’s a reasonable pattern for content below the fold that a user has to scroll to see. It’s actively harmful applied to a hero section, which is, by definition, the first thing rendered and always in view, there’s nothing to “reveal on scroll,” you’ve just made the largest paint on the page wait for JavaScript to download, parse, execute, hydrate React, stand up an observer, and complete a 0.6s CSS transition before it counts as painted.
Under desktop’s lack of throttling that whole chain finishes fast enough to hide the defect. Under mobile throttling it’s exactly what you’d expect to blow up an LCP number.
| Metric | Before | After |
|---|---|---|
| Mobile Performance score | 76 | 99 |
| Mobile LCP | 6.1s | 1.2s |
| Accessibility score | 96 | 100 |
The fix was removing two CSS classes from the hero section. Nothing else changed. Mobile Performance went from 76 to 99, mobile LCP from 6.1s to 1.3s, later 1.2s once we also fixed a couple of accessibility findings from the same audit: an h4skipping a heading level in the footer nobody had noticed, and a button colour-contrast ratio of 3.07:1 against WCAG’s 4.5:1 requirement, fixed by swapping which of our two existing brand oranges was the resting-state fill versus the hover-state fill, no new colour invented, just reordered.
If you’re using any animate-on-scroll library or hand-rolled IntersectionObserver pattern: audit whether it’s touching anything above the fold. It almost certainly shouldn’t be.
What actually held up
The stuff we expected to be painful mostly wasn’t. Static-exporting three fairly complex client-side tools (a cost calculator, a site health checker hitting the real PageSpeed Insights API, an llms.txt generator) worked cleanly once we parity-tested each one’s logic against the old vanilla-JS versions across tens of thousands of generated input combinations, tedious to set up, boring to run, and it caught every behavioural drift before launch instead of after. Deploying via an upload API using the resumable TUS protocol instead of FTP was a smaller lift than expected too.
The pattern across all four real bugs above is the same: none of them showed up in local dev. Every one needed either a genuinely cross-referenced check (diffing deployed output against actual requests), a live production test immediately after deploy, or a real third-party tool (Lighthouse under real throttling profiles) rather than “it looks fine on my machine.” Static export in particular removes a lot of the usual safety net, there’s no server runtime catching a bad route at request time, just files on disk and whatever your web server does with a URL it wasn’t told about.
Frequently asked questions
Does the segment-file path bug affect Next.js export on Linux or Mac?
No, it's specific to building the static export on Windows. The bug is in how the export step joins path segments, using a backslash where the output (a web path) needs a forward slash. If your CI or build machine runs Linux or macOS, you shouldn't hit this; it only showed up for us because the build happened on a Windows machine.
Is the .htaccess rewrite fix specific to Apache, or does it affect Nginx too?
This specific fix is Apache-only; it's a mod_rewrite rule in .htaccess. Nginx handles static file serving and clean URLs differently (typically with try_files directives), so the exact syntax doesn't carry over. The underlying lesson does, though: any rewrite rule pattern written for single-segment routes needs to be re-checked once you add a nested route, regardless of which web server you're on.
Do these bugs apply if I'm using the Next.js Node server instead of static export?
No, bugs 1 through 3 are all specific to static export deployed on a plain web server with no Node runtime. Running next start or deploying to a platform with a Node/Edge runtime (Vercel, a Node server behind a reverse proxy) means Next.js itself handles routing at request time, so there's no .htaccess rewrite logic to get wrong and no exported segment-file paths to misplace. The mobile LCP bug (an animation library delaying the largest paint until after hydration) is unrelated to the rendering mode and can happen either way.
How do I check if my own site has the same LCP problem?
Run a real PageSpeed Insights check specifically on mobile, not just desktop; the throttling difference is exactly what hides this class of bug. If your LCP is high but your largest image is small and well-optimised, check whether the LCP element (or its container) has an opacity, transform or animation class that only resolves after JavaScript runs, a scroll-reveal library, a fade-in wrapper, anything gated behind an IntersectionObserver or a mount effect. If that element is above the fold, it shouldn't be gated behind anything at all.
Related
Weighing a similar migration, or chasing a production performance issue?
Tell us what you're working on. We reply within one business day with honest first thoughts, whether that's a call, a rough estimate, or a pointer in a better direction.
