all posts

Versatile Npm-Free Web Stack

Simon Ramstedt, · last updated

Heavy full-stack frameworks like Next.js are less useful than ever because AI can assemble the underlying building blocks with more flexibility and specificity.

Meanwhile, the declarativeness of React, the terseness of Tailwind and the composability of Express still hold a lot of value. However, I've always disliked the heaviness and wonky npm dependencies they brought. Combining Hono, Preact and Tailpipe gets you all these things without the dependencies.

Below we'll show how to conveniently set this stack up, sourcing Preact, Hono, etc directly via git without pulling opaque bundles from the npm registry.

StackBuildServerFrontendStyling*PackagesInstall size
Next.jsbuilt-inbuilt-inReactTailwind42342 MB
TanStack StartViteh3ReactTailwind167132 MB
commonViteExpressReactTailwind10562 MB
oursesbuildHonoPreactTailpipe014 MB

* Adding Tailwind (or Tailpipe) is not required in any of the stacks.
Packages downloaded from npm.
Or bun / qn, which bundle with built-ins instead of an external tool.

In our stack, Hono serves APIs and static files, Preact renders a SPA and Tailpipe generates Tailwind classes at runtime. There are no transitive dependencies.

Setup. We have a companion repo with minimal full-stack example setups for Node, Bun and our own experimental Qn runtime. We'll continue with Node for now, you can read more about Bun and Qn below.

package.jsonfull file →
...
"scripts": {
  "prepare": "node install.js",
  "start": "node server.js",
  "dev": "node --watch server.js --dev"
},
"sourceDependencies": {
  "preact": {
    "git": "https://github.com/preactjs/...
    "rev": "21dd6d04c1a9a43e5b60976bb5eb...
    "exports": { ".": "./src/index.js", ...
  },
  ...
}

While the standard dependencies field in package.json accepts git URLs, many packages don't work that way (e.g. Hono) or require different build tools. So we declare them in a custom sourceDependencies field instead, each pinned to a git repo and commit hash. The optional exports map covers packages whose source layout doesn't match their published dist/: it points the install step at the right source files.

A small install.js script (run via the prepare hook on every npm install1) realizes those declarations: each repo is cloned at its pinned commit and built for Node via esbuild. The result is a node_modules dir that is functionally equivalent to one with npm downloads. On Bun the script is simpler, skipping esbuild and only rewriting exports to point at source. On Qn no script is needed at all: qn install handles sourceDependencies natively.

The pinned commit hashes provide full source-based supply chain integrity2. In the past, hand-rolling a manifest like this might have been inconvenient. With AI coding, it's practical and explicit.

The server.js runs directly without bundling and shells out to esbuild to build the frontend while rebuilding automatically in --dev mode.

Runtimes. Aside from Node, this stack runs even more elegantly on Bun and Qn. Both come with integrated bundlers and run Hono without @hono/node-server. They also accept unbundled Typescript dependencies in node_modules, which lets you skip the build step during install and get end-to-end type checking across client and server.

RuntimeBundlerHTTPBinary sizeExample
Nodeesbuild@hono/node-server118 MB/node
BunBun.buildbuilt-inBun.servebuilt-in97 MB/bun
Qnqn:bundlebuilt-inqn:httpbuilt-in3.7 MB/qn
  1. Or bun install. It can also be invoked manually as node install.js. qn install doesn't need the script at all.
  2. A git commit hash is a content hash over the full source tree. Pinning commit hashes across repos extends git's Merkle DAG into a single tree covering all dependencies.