Next.js is a full front-end web framework. Vite is a build tool so they don't easily compare. But if you're building a single-page app ("SPA"), the difference isn't that big, especially if you bolt on a routing library which is something that Next.js has built in.
My SPA is a relatively straight forward one. It's a React app that uses wonderful Mantine UI framework. The app is CRM for real-estate agents that I've been hacking on with my wife. SEO is not a concern because you can't do anything until you've signed in. So server-side rendering is not a requirement. In that sense, it's like loading Gmail. Yes, users might want a speedy first load when they open it in a fresh new browser tab, but the static assets are most likely going to be heavily (browser) cached by the few users it has.
With that out of the way, let's skim through some of the differences.
Build times
Immediately, this is a tricky one to compare because Next.js has the ability to cache. You get that .next/cache/
directory which is black magic to me, but it clearly speeds things up. And it's incremental so the caching can help partially when only some of the code has changed.
Running, npm run build && npm run export
a couple of times yields:
Next.js
Without no .next/cache/
directory
Total time to run npm run build && npm run export
: 52 seconds
With the .next/cache/
left before each build
Total time to run npm run build && npm run export
: 30 seconds
Vite
Total time to run npm run build
: 12 seconds
A curious thing about Vite here is that its output contains a measurement of the time it took. But I ignored that and used /usr/bin/time -h ...
instead. This gives me the total time.
I.e. the output of npm run build
will say:
✓ built in 7.67s
...but it actually took 12.2 seconds with /usr/bin/time
.
Build artifacts
Perhaps not very important because Next.js automatically code splits in its wonderfully clever way.
Next.js
❯ du -sh out 1.8M out
❯ tree out | rg '\.js|\.css' | wc -l 52
Vite
❯ du -sh dist 960K dist
and
❯ tree dist/assets dist/assets ├── index-1636ae43.css └── index-d568dfbf.js
Again, it's probably unfair to compare at this point. Most of the weight of these static assets (particularly the .js
files) is due to Mantine components being so heavy.
Routing
This isn't really a judgment in any way. More of a record how it differs in functionality.
Next.js
In my app, that I'm switching from Next.js to Vite + wouter, I use the old way of using Next.js which is to use a src/pages/*
directory. For example, to make a route to the /account/settings
page I first create:
// src/pages/account/settings.tsx
import { Settings } from "../../components/account/settings"
const Page = () => {
return <Settings />
}
export default Page
I'm glad I built it this way in the first place. When I now port to Vite + wouter, I don't really have to touch that src/components/account/settings.tsx
code because that component kinda assumes it's been invoked by some routing.
Vite + wouter
First I installed the router in the src/App.tsx
. Abbreviated code:
// src/App.tsx
import { Routes } from "./routes"
export default function App() {
const { myTheme, colorScheme, toggleColorScheme } = useMyTheme()
return (
<ColorSchemeProvider
colorScheme={colorScheme}
toggleColorScheme={toggleColorScheme}
>
<MantineProvider withGlobalStyles withNormalizeCSS theme={myTheme}>
<Routes />
</MantineProvider>
</ColorSchemeProvider>
)
}
By the way, the code for Next.js looks very similar in its src/pages/_app.tsx
with all those contexts that Mantine make you wrap things in.
And here's the magic routing:
// src/routes.tsx
import { Router, Switch, Route } from "outer"
import { Home } from "./components/home"
import { Authenticate } from "./components/authenticate"
import { Settings } from "./components/account/settings"
import { Custom404 } from "./components/404"
export function Routes() {
return (
<Router>
<Switch>
<Route path="/signin" component={Authenticate} />
<Route path="/account/settings" component={Settings} />
{/* many more lines like this ... */}
<Route path="/" component={Home} />
<Route>
<Custom404 />
</Route>
</Switch>
</Router>
)
}
Redirecting with router
This is a made-up example, but it demonstrates the pattern with wouter compared to Next.js
Next.js
const { push } = useRouter()
useEffect(() => {
if (user) {
push('/signedin')
}
}, [user])
wouter
const [, setLocation] = useLocation()
useEffect(() => {
if (user) {
setLocation('/signedin')
}
}, [user])
Linking
Next.js
import Link from 'next/link'
// ...
<Link href="/settings" passHref>
<Anchor>Settings</Anchor>
</Link>
wouter
import { Link } from "wouter"
// ...
<Link href="/settings">
<Anchor>Settings</Anchor>
</Link>
Getting a query string value
Next.js
import { useRouter } from "next/router"
// ...
const { query } = useRouter()
if (query.name) {
const name = Array.isArray(query.name) ? query.name[0] : query.name
// ...
}
wouter
import { useSearch } from "wouter/use-location"
// ...
const search = useSearch()
const searchParams = new URLSearchParams(search)
if (searchParams.get('name')) {
const name = searchParams.get('name')
// ...
}
Conclusion
The best thing about Next.js is its momentum. It gets lots of eyes on it. Lots of support opportunities and great chance of its libraries being maintained well into the future. Vite also has great momentum and adaptation. But wouter is less "common".
Comparing apples and oranges is often counter-productive if you don't take all constraints and angles into account and those are usually quite specific. In my case, I just want to build a single-page app. I don't want a Node server. In fact, my particular app is a Python backend that does all the API responses from a fetch
in the JavaScript app. That Python app also serves the built static files, including the dist/index.html
file. That's how my app can serve the app straight away if the current URL is something like /account/settings
. A piece of Python code (more or less the only code that doesn't serve /api/*
URLs) collapses all initial serving URLs to serve the dist/index.html
file. It's a classic pattern and honestly feels a bit dated in 2023. But it works. And what's so great about all of this is that I have a multi-stage Dockerfile
that first does the npm run build
(and some COPY --from=frontend /home/node/app/dist ./server/out
) and now I can "lump" together the API backend and the front-end code in just 1 server (which I host on Digital Ocean).
If you had to write a SPA in 2023 what would you use? In particular, if it has to be React. Remix is all about server-side rendering. Create-react-app is completely unsupported. Building it from scratch yourself rolling your own TypeScript + Eslint + Rollup/esbuild/Parcel/Webpack does not feel productive unless you have enough time and energy to really get it all right.
In terms of comparing the performance between Next.js and Vite + wouter, the time it takes to build the whole app is actually not that big a deal. It's a rare thing to do. It's something I do after a long coding/debugging session. What's more pressing is how npm run dev
works.
With Vite, I type npm run dev
and hit Enter. Faster than I can almost notice, after hitting Enter I see...
VITE v4.4.6 ready in 240 ms ➜ Local: http://localhost:3000/ ➜ Network: use --host to expose ➜ press h to show help
and I'm ready to open http://localhost:3000/
to play. With Next.js, after having typed npm run dev
and Enter, there's this slight but annoying delay before it's ready.
Comments