TL;DR
I ended up choosing a hybrid: Next.js 14 for static content pages + a separately lazy-loaded client shell for the game runtime page.
But that wasn't a snap decision. I spent 3 days building the same MVP twice, hit the Next.js static export dynamic route 404 trap, and dealt with Vue's SEO plugin problem. If you're making a similar decision, the process is probably more useful than the conclusion.
Environment
- Node 20.11 / npm 10.5
- Next.js 14.2.3 (App Router +
output: 'export') - Vue 3.4 + Vite 5.2 + vue-router 4
- Test devices: MacBook Pro M1 (build tests), iPhone 12 (Lighthouse mobile tests)
- Deployment target: Vercel static hosting (zero server)
What Were My Constraints?
Before writing any code, I listed three hard constraints:
- SEO had to work: Most traffic for a game site comes from search engines. Game detail pages must be crawlable.
- First load had to be fast: LCP under 2.5s on mobile 4G.
- The game iframe couldn't block the main thread: While browsing the list page, no game runtime code should load.
These three constraints ended up driving the entire decision.
Day 1: Next.js Version — Static Export Taught Me a Lesson
What I Built
Next.js 14 App Router with this route structure:
-
/homepage (SSG) -
/games/[category]category page (SSG) -
/play/[id]game runtime page (client-side rendered)
I used output: 'export' for pure static export and deployed to Vercel.
Trap 1: Dynamic Routes 404
After next build, the homepage and category pages generated HTML correctly. But /play/[id] all returned 404.
After digging in: App Router with output: 'export' doesn't support dynamic routes by default unless you explicitly define generateStaticParams. This is heavily discussed in the community; developers on V2EX have reported that "the latest Next.js App Router doesn't support dynamic routes with static generation."
Worse, even after defining generateStaticParams, all game IDs have to be pre-generated at build time. If the game count grows to hundreds, every new game triggers a full rebuild, and CI time grows exponentially.
Solutions I Tried
I tried @falsefoundation/next-dynamic-exports, which supports dynamic routes by generating fallback pages, but it requires web server rewrite rules (Nginx try_files), adding deployment complexity.
Another approach was to turn the game runtime page from a dynamic route into a client-rendered static shell — the /play page reads the game ID via useSearchParams after loading. This bypasses the generateStaticParams limitation, but the game runtime page is no longer prerendered, so SEO suffers. My trade-off: the game runtime page has low SEO value anyway (users arrive from the detail page), so it's acceptable.
Next.js Version Data
- Homepage LCP: 1.2s (mobile 4G throttling)
- First-load JS (gzip): ~85KB
- Build time: 12s (including 6 pre-generated game pages)
Day 2: Vue 3 + Vite Version — Genuinely Lightweight
What I Built
Vue 3.4 + Vite 5.2 + vue-router 4, same route structure as the Next.js version.
The Advantages Were Obvious
Vite's dev experience is excellent. Cold start under 1 second, hot updates nearly imperceptible. Build time was only 6 seconds, half of Next.js.
First-load JS (gzip) was about 50KB, nearly 40% less than Next.js. Vue 3's runtime is lightweight by itself, and Vite's tree-shaking leaves nothing extra.
But SEO Became a Problem
Vue + Vite defaults to SPA. The homepage and category pages only contain <div id="app"></div> in the HTML — crawlers see no content.
The solution was to install vite-plugin-seo-prerender, which prerenders the SPA into static HTML files at build time. Configuration is simple:
// vite.config.ts
import seoPrerender from 'vite-plugin-seo-prerender'
export default defineConfig({
plugins: [
seoPrerender({
routes: ['/', '/games/puzzle', '/games/sports']
})
]
})
But the plugin has a limitation: it's only suitable for generating static HTML for a small number of pages. If the game count grows to hundreds, every game detail page needs prerendering, and build time becomes uncontrollable.
Another option is Nuxt 3, Vue's meta-framework with built-in SSG. But that introduces another framework, contradicting the "lightweight" goal.
Vue Version Data
- Homepage LCP: 1.5s (mobile 4G throttling, after prerendering)
- First-load JS (gzip): ~50KB
- Build time: 6s
Day 3: The Key Finding — It's Not the Framework, It's the iframe Initialization Timing
After both versions were running, I did one thing: recorded the full timeline from homepage to clicking "Start Game" using Chrome DevTools Performance panel on both.
Something counterintuitive came up: the performance difference between the two versions was far smaller than I expected.
The Next.js version had 35KB more first-load JS, but on 4G that's about 200ms of download time. The real time sink while browsing the list page wasn't the framework's JS size — it was the game iframe initialization.
In the default implementation, the iframe was initialized when the game detail page mounted. Even if the iframe src pointed to an empty placeholder page, creating the contentWindow and initializing the sandbox environment still consumed main thread time. On a Moto G Power, that single operation took about 400ms.
The correct approach is the facade pattern: render only a lightweight static placeholder (thumbnail + play button) before the iframe mounts, and inject the actual iframe only when the user clicks "Start." This matches Lighthouse's recommendation for deferring third-party resources.
There's a Next.js static wiki case in the DEV community that uses the facade pattern to move a YouTube iframe from hydration-time mount to click-time load, bringing mobile TBT down from destructive levels to acceptable.
This means: the framework choice's impact on first-load performance is far smaller than the iframe initialization strategy's impact. I wrote this down because it directly changed my selection criteria.
Final Choice: Hybrid Architecture
Based on the three-day comparison, I made these decisions:
Static content pages (homepage, category pages, detail pages) use Next.js SSG. Because:
- SEO works out of the box, no extra plugins
- Dynamic routes have limitations, but the game detail page ID count is manageable (only 6 games initially)
- If the game count grows to 200+, a Headless CMS with ISR can be introduced, but that requires a server and breaks the zero-backend constraint
The game runtime page uses an independent client-rendered shell. Because:
- The game runtime page has low SEO value and doesn't need prerendering
- Use
next/dynamicwithssr: falseto lazy-load the game iframe component - Facade pattern: initialize the iframe only after the user clicks "Start"
The Vue version wasn't maintained. It builds faster and has a smaller bundle, but SEO needs extra plugins, and if the game count grows, the prerendering solution's scalability is worse than Next.js's SSG system.
Here's the concrete implementation:
// components/GameLauncher.tsx
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
const GameIframe = dynamic(() => import('./GameIframe'), {
ssr: false,
loading: () => <div className="h-full bg-zinc-900 animate-pulse" />
})
export default function GameLauncher({ game }) {
const [started, setStarted] = useState(false)
if (!started) {
return (
<button
onClick={() => setStarted(true)}
className="relative w-full aspect-video rounded-lg overflow-hidden"
>
<img src={game.thumbnail} alt={game.title} className="w-full h-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center">
<svg width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="30" fill="rgba(0,0,0,0.6)" />
<polygon points="26,20 26,44 46,32" fill="white" />
</svg>
</div>
</button>
)
}
return <GameIframe src={game.entry} gameId={game.id} />
}
ssr: false ensures the iframe component isn't bundled into the HTML during server rendering, and only loads from the client after the user clicks. This matches Next.js's official lazy-loading advice: for heavy components that don't participate in SSR, use next/dynamic with ssr: false to reduce the initial bundle.
Data Comparison
| Metric | Next.js 14 | Vue 3 + Vite |
|---|---|---|
| First-load JS (gzip) | ~85KB | ~50KB |
| Homepage LCP (4G) | 1.2s | 1.5s (after prerendering) |
| Build time | 12s | 6s |
| Dynamic route support | Needs generateStaticParams | Built-in |
| SEO | Out of the box | Needs extra plugins |
| Game runtime lazy load | next/dynamic native support | Manual implementation |
Test conditions: MacBook Pro M1 build, iPhone 12 + Chrome DevTools 4G throttling, 10 runs each, median.
Note: Vue's LCP was actually slower than Next.js after prerendering. The reason is that vite-plugin-seo-prerender still includes the full Vue runtime in the generated HTML, while Next.js SSG outputs cleaner HTML with lower hydration cost.
Trade-offs
- Next.js build time is longer: 12s vs 6s. Every new game requires a rebuild. If the game count reaches 50, build time could exceed 30 seconds.
- The Vue version was abandoned: It has better dev experience and a smaller bundle, but needs extra work on SEO and scalability. If your game site doesn't need SEO — an internal tool or pure entertainment site — Vue + Vite is the better choice.
- The hybrid architecture adds complexity: Static pages and the runtime page use different rendering strategies, requiring understanding of two sets of logic.
Unresolved
- If the game count exceeds 100,
generateStaticParamsfull pre-generation becomes a bottleneck, requiring ISR or a Headless CMS — but that's no longer zero-backend. - I didn't dig deep into Vue's prerendering solution.
vite-plugin-prerender-staticsupports multi-route generation and SEO meta tags, which might be enough if the game count is small. - Astro is another direction worth watching. A developer built a 50+ game site with Astro + vanilla JS, keeping the JS bundle under 100KB gzip and emphasizing "each game only loads the JavaScript it actually needs."
Repo
Both MVP versions are organized, including Next.js and Vue comparison branches. The test data comes from a local environment. If you get different results on real hardware, I'd love to hear about it.
Top comments (0)