Why We Ditched Next.js for Our Developer Console
Next.js is a great framework. We used it wrong.
smplkit’s developer console — the authenticated dashboard where customers manage environments, API keys, and product configurations — was built with Next.js using output: "export". Static export generates HTML files at build time, which we deploy to S3 behind CloudFront. No Node.js server, no SSR, just static files on a CDN. Simple and cheap.
It worked well for the first few pages. Then we added dynamic routes, and the simplicity collapsed.
The Problem with Static Export and Dynamic Routes
Next.js static export produces one HTML file per route. A page at /account/settings generates account/settings.html. S3 can serve that directly. Straightforward.
But a page at /environments/[id] — where [id] could be production, staging, or anything else — generates a single placeholder file. There’s no environments/production.html. There can’t be, because the values aren’t known at build time. They come from the database.
This creates a routing gap: the browser requests /environments/production, S3 doesn’t have a file at that path, and something has to bridge the two.
The Workaround Stack
We bridged the gap with three layers of workarounds, each solving a problem created by the previous one.
Layer 1: CloudFront custom_error_responses. When S3 returned 404 for a path it didn’t have, CloudFront intercepted the error and served the app’s fallback HTML page instead. The browser loaded the page, React hydrated, and client-side routing took over.
This broke our backend API. CloudFront’s error interception applies to the entire distribution, including the /api/* behavior that routes to our FastAPI backend. When the API returned a legitimate 404 with a JSON:API error body — “this resource doesn’t exist” — CloudFront threw away the JSON and returned the HTML shell with a 200 status. The frontend received HTML when it expected JSON, and because it was a 200, the API client tried to parse it as a success response.
Layer 2: CloudFront Function with per-route pattern matching. We replaced custom_error_responses with a viewer-request function that rewrites URL paths to the correct HTML file. Requests for /environments/production get rewritten to the placeholder page. Requests for /api/* pass through untouched.
This works, but the function contains a list of every dynamic route pattern in the application. Every new dynamic route — and with 12+ products planned, there will be many — requires updating the CloudFront Function.
Layer 3: window.location.pathname parsing in every dynamic page. Even after CloudFront serves the right HTML file, Next.js route params contain the literal placeholder value, not production. Every dynamic page component has a workaround that reads the actual URL from window.location.pathname and extracts the real identifier. Our not-found.tsx had a DynamicRouteRedirect component that used window.location.replace() to force full page reloads for dynamic routes, producing a visible flash instead of smooth SPA navigation.
Three layers of workarounds for one conceptual problem: the deployment model doesn’t know about URLs that are only defined at runtime.
The Realization
The tipping point wasn’t any single bug. It was a conversation about the twelfth product.
smplkit is a multi-product platform. Each product — Config, Flags, Logging, Jobs, and so on — has its own set of dynamic routes in the console. Every product page, every instance detail view, every environment-scoped configuration screen is a dynamic route. When we mapped out the route tree for twelve products, we were looking at dozens of patterns in the CloudFront Function and dozens of window.location workarounds in page components.
That’s when we stopped and asked the obvious question: why are we using a static site generator for a dynamic, authenticated SPA?
What Next.js Was Giving Us
We audited what Next.js features the console actually uses:
- Server-side rendering: No. Everything is statically exported.
- React Server Components: No. All components are client-side.
- ISR (Incremental Static Regeneration): No. No build-time data fetching.
- SEO optimization: No. The entire app is behind authentication.
- API routes: No. The backend is a separate FastAPI service.
- Image optimization: No.
next/imagedoesn’t work in static export without a custom loader. - Middleware: Not available in static export mode.
The only Next.js feature in active use was file-based routing — and that was the source of all our problems.
The Migration to Vite + React Router
We migrated the console to Vite as the build tool and React Router v7 for client-side routing. The application is now a standard SPA that produces a single index.html entry point.
The deployment model is unchanged — S3 behind CloudFront. But the CloudFront Function went from a route-pattern lookup table to a single rule: if the request path doesn’t have a file extension, serve index.html. React Router handles everything from there.
Dynamic routes just work. useParams() returns production when the URL is /environments/production. No placeholder files, no window.location parsing, no per-route infrastructure awareness.
The migration itself was largely mechanical:
next/link→ React Router<Link>useRouter()→useNavigate()usePathname()→useLocation()- File-based routing → explicit route definitions in a single file
All React components, Tailwind styles, API client code, and business logic carried over unchanged. The riskiest part was rebuilding the route tree explicitly, but the existing file-based structure served as a direct map.
What We Gained
No more infrastructure-per-route. Adding a new product with a dozen dynamic routes requires zero CloudFront Function updates. The infrastructure is route-agnostic.
Real SPA navigation. Every transition is smooth. No full-page reloads, no flash, no workarounds. The dashboard feels like a desktop application.
Faster development feedback. Vite’s dev server starts in under a second with near-instant hot module replacement. The Next.js dev server was slower and getting slower as the app grew.
Simpler mental model. There’s no SSG/SSR/ISR conceptual overhead. It’s React components, a router, and an API client. The entire frontend architecture fits in one sentence.
When Next.js Is the Right Choice
Next.js is excellent for what it’s designed for: public-facing websites where SEO, server-side rendering, and incremental static regeneration matter. Marketing sites, blogs, documentation, e-commerce storefronts — these are the sweet spot.
If your pages need to rank in search engines, if your content exists at build time, if you benefit from server-rendered HTML for performance or social sharing — Next.js is a strong choice. Our marketing site at www.smplkit.com runs on a different stack that’s built for static content, and it’s the right fit there.
But an authenticated developer console with dynamic routes that load data from APIs at runtime is not that use case. We were paying the complexity tax of a server-rendering framework while using none of its capabilities.
The Lesson
The mistake wasn’t choosing Next.js initially. When the console had five static pages, it was fine. The mistake was not revisiting the decision when the symptoms appeared. The window.location workaround was a symptom. The CloudFront Function route table was a symptom. The full-page flash on navigation was a symptom. Each one was fixable in isolation, which is exactly why we kept fixing them instead of questioning the foundation.
When your workarounds start needing workarounds, the tool is wrong for the job.
The smplkit developer console is at app.smplkit.com. Learn more about our architecture in the docs.