If you've done React for a while, you most likely remember Create React App. It was/is a prepared config that combines React with webpack, and eslint. Essentially, you get immediate access to making apps with React in a local dev server and it produces a complete build artefact that you can upload to a web server and host your SPA (Single Page App). I loved it and blogged much about it in distant past.

The create-react-app project died, and what came onto the scene was tools that solved React rendering configs with SSR (Server Side Rendering). In particular, we now have great frameworks like Gatsby, Next.js, Remix, and Astro. They're great, especially if you want to use server-side rendering with code-splitting by route and that sweet TypeScript integration between your server (fs, databases, secrets) and your rendering components.

However, I still think there is a place for a super light and simple SPA tool that only adds routing, hot module reloading, and build artefacts. For that, I love Vite + Wouter. At least for now :)
What's so great about it? Speed

Quickstart


❯ npm create vite@latest my-vite-react-ts-app -- --template react-ts

...

Done. Now run:

  cd my-vite-react-ts-app
  npm install
  npm run dev

A single-page app needs routing, so let's add wouter to it and add it to the app entry point:

❯ my-vite-react-ts-app
❯ npm install && npm install wouter

And edit the default created src/App.tsx to something like this:


import "./App.css";
import { Routes } from "./routes";

function App() {
  // You might need other wrapping components such as theming providers, etc.
  return <Routes />;
}

export default App;

And the src/routes.tsx:


import { Route, Router, Switch } from "wouter";

export function Routes() {
  return (
    <Router>
      <Switch>
        <Route path="/" component={Home} />
        <Route>
          <Custom404 />
        </Route>
      </Switch>
    </Router>
  );
}

function Custom404() {
  return <div>Page not found</div>;
}

function Home() {
  return <div>Hello World</div>;
}

That's it! Let's test it with npm run dev and open http://localhost:5173


❯ npm run dev


  VITE v5.4.1  ready in 97 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help


Hot module reloading works as expected.
Let's build it now:


❯ npm run build

vite v5.4.1 building for production...
✓ 42 modules transformed.
dist/index.html                   0.46 kB │ gzip:  0.30 kB
dist/assets/index-DiwrgTda.css    1.39 kB │ gzip:  0.72 kB
dist/assets/index-Ba6YWXXy.js   148.08 kB │ gzip: 48.29 kB
✓ built in 340ms

It says "built in 340ms" but that doesn't include the total time of the whole npm run build execution. To get the total time, use the built in time command:


❯ /usr/bin/time npm run build

...

✓ built in 340ms
        1.14 real         2.28 user         0.13 sys

So about 1.1 seconds as the wall clock goes.

Add basic routing

As you can imagine, adding more routes that point to client-side components is as simple as:


import { Route, Router, Switch } from "outer";

+import Charts from "./components/charts"

export function Routes() {
  return (
    <Router>
      <Switch>
        <Route path="/" component={Home} />
+       <Route path="/charts" component={Charts} />
        <Route>
          <Custom404 />
        </Route>
      </Switch>
    </Router>
  );
}

Using a stack like Vite and Wouter which isn't as feature-packed as Remix and Next.js doesn't have to be super optimized. It's pretty near to being production deployment-worthy. Just one thing missing in my view; lazy route-based code-splitting. Let's build that. Here's the new routes.tsx:


import { lazy, Suspense } from "react";
import { Route, Router, Switch } from "wouter";

type LazyComponentT = React.LazyExoticComponent<() => JSX.Element>;

function LC(Component: LazyComponentT, loadingText = "Loading") {
  return () => {
    return (
      <Suspense fallback={<p>{loadingText}</p>}>
        <Component />
      </Suspense>
    );
  };
}

const Charts = LC(lazy(() => import("./components/charts")));

export function Routes() {
  return (
    <Router>
      <Switch>
        <Route path="/" component={Home} />
        <Route path="/charts" component={Charts} />
        <Route>
          <Custom404 />
        </Route>
      </Switch>
    </Router>
  );
}

function Custom404() {
  return <div>Page not found</div>;
}

function Home() {
  return <div>Hello World</div>;
}

If you run npm run build now, you'll see something like this:


❯ npm run build

...

dist/index.html                   0.46 kB │ gzip:  0.30 kB
dist/assets/index-DiwrgTda.css    1.39 kB │ gzip:  0.72 kB
dist/assets/charts-qo6bqIo2.js    0.12 kB │ gzip:  0.13 kB
dist/assets/index-JUI4kknP.js   149.25 kB │ gzip: 48.81 kB

In particular, there's a new .js file that is prefixed with the word charts-. How easy was that?!

Compared to Next.js

Next.js is wonderful. But it's a bit heavy. Let's build something with npx create-next-app@latest which is very similar. I.e. SPA with React, TypeScript, and route-based code-splitting.

You just have to make this change to next.config.mjs to make a SPA:


/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export",
};

export default nextConfig;

Now, npm run build will generate a directory called out which you can upload to a CDN.

But let's add another component to make the comparison fair. Something like this:


// This is src/app/charts/page.tsx

export default function Charts() {
  return <div>Charts</div>;
}

So easy! Thank you Next.js. Run npm run build again and look at the files in out/_next/static:


 out/_next/static
 ├── X6P949yoE-Ou9K46Apwab
 │   ├── _buildManifest.js
 │   └── _ssgManifest.js
 ├── chunks
 │   ├── 23-bc0704c1190bca24.js
 │   ├── app
 │   │   ├── _not-found
 │   │   │   └── page-05886c10710171db.js
+│   │   ├── charts
+│   │   │   └── page-3bd6b64ccd38c64e.js
 │   │   ├── layout-3e7df178500d1502.js
 │   │   └── page-121d7018024c0545.js
 │   ├── fd9d1056-2821b0f0cabcd8bd.js
 │   ├── framework-f66176bb897dc684.js
 │   ├── main-app-00df50afc5f6514d.js
 │   ├── main-c3a7d74832265c9f.js
 │   ├── pages
 │   │   ├── _app-6a626577ffa902a4.js
 │   │   └── _error-1be831200e60c5c0.js
 │   ├── polyfills-78c92fac7aa8fdd8.js
 │   └── webpack-879f858537244e02.js
 └── CSS
     └── 876d048b5dab7c28.css

(technically I cheated here, because adding another route changes the hashes from some of the other chunks but you get the point)

Building it with npm run build takes...


❯ /usr/bin/time npm run build


> my-nextjs-ts-app@0.1.0 build
> next build

  ▲ Next.js 14.2.5

   Creating an optimized production build ...
 ✓ Compiled successfully
 ✓ Linting and checking validity of types
 ✓ Collecting page data
 ✓ Generating static pages (6/6)
 ✓ Collecting build traces
 ✓ Finalizing page optimization

Route (app)                              Size     First Load JS
┌ ○ /                                    140 B          87.3 kB
├ ○ /_not-found                          871 B            88 kB
└ ○ /charts                              140 B          87.3 kB
+ First Load JS shared by all            87.1 kB
  ├ chunks/23-bc0704c1190bca24.js        31.6 kB
  ├ chunks/fd9d1056-2821b0f0cabcd8bd.js  53.6 kB
  └ other shared chunks (total)          1.86 kB


○  (Static)  prerendered as static content

        6.26 real         9.89 user         1.42 sys

So about 6+ seconds.

Comparing the time npm run build takes, between Next.js and Vite+Wouter looks like this:


❯ hyperfine "cd my-vite-react-ts-app && npm run build" "cd my-nextjs-ts-app && npm run build"

...


Summary
  cd my-vite-react-ts-app && npm run build ran
    5.90 ± 0.63 times faster than cd my-nextjs-ts-app && npm run build

In other words, the Vite+Wouter SPA is 6x faster at building than the equivalent Next.js SPA.

Summary

The npm run build time isn't massively important. It's not the kind of operation you do super often and oftentimes it's something you can kick off and walk away from in a sense. Kinda.

Where it matters, to me, is that "instantness" feeling you get when you type npm run dev and you can (almost) immediately start to work. It makes for happiness.
To properly compare that experience between Vite+Wouter vs. Next.js I wrote a hacky script which spawns the npm run dev the background and then every 10ms checks if it can successfully HTTP GET the http://localhost:5173 (or http://localhost:3000.
When run that, a couple of times, the numbers I get are:

  • Vite + Wouter: Getting 200 OK: 266.6ms
  • Next.js: Getting 200 OK: 2.414s

And that matters; to me! Tease me all you like for short attention span, but I often have a thought that I want to code and that flow gets disrupted if there's a sudden pause before I can test the dev server.

Bonus: Bun

If you know me, you know I'm big fan of Bun and have always thought one of its coolest features is its ability to start up quickly.

Out of curiosity, I used bun create vite and created a replicate of the Vite+Wouter but using bun (v1.1.22) instead of node (v20.16). Comparing their build times...

❯ hyperfine "cd my-vite-react-ts-app && npm run build" "cd my-vite-bun-react-ts-app && bun run build"

...

Summary
  cd my-vite-bun-react-ts-app && bun run build ran
    1.53 ± 1.34 times faster than cd my-vite-react-ts-app && npm run build

I.e. Using bun to build the Vite+Wouter app is 1.53 times faster than using node.

Comments

Your email will never ever be published.

Related posts