A single light pipeline splitting into frontend and backend streams, with a gray-blue British shorthair cat watching from a windowsill.

Vavite: Va Vite, Run Vite on the Server

The Gray Cat
The Gray Cat
0 views

Vite transformed frontend development with its instant dev server and lightning-fast hot module replacement. But the moment you step past the browser and into server-side territory, the magic tends to fade. Your client code runs through Vite, while your own server code, the TypeScript and JSX that actually answers requests, needs a completely separate toolchain to transpile and watch. That usually means ts-node plus nodemon, or tsx watch, sitting alongside Vite like a second, slightly grumpier engine. Two transpilers, two configs, two sets of module-resolution rules, and crucially, no hot reloading on the backend.

Vavite closes that gap. It is a Vite plugin that lets you use Vite itself to transpile and serve all of your server code, giving you a single unified pipeline for both ends of your application and, best of all, true module-level hot replacement on the server. When you edit a route handler, Vite swaps the module in place instead of nuking the entire Node process. It is the missing piece that makes "Vite on the server" feel as good as Vite in the browser.

The name is a little French pun, "va vite" meaning "go fast," and the library is built by Fatih Aygün, who also created the Rakkas SSR framework on top of it. It is MIT-licensed, tiny, and adds zero runtime overhead in production because all of its dev-time machinery gets stripped out of the final build.

Why Bother When tsx Exists

It is worth being precise about what Vavite buys you, because at a glance tsx watch looks like it does the same job. The difference comes down to three things.

First, there is the two-transpiler tax. Without Vavite, Vite handles your client bundle while nodemon or tsx handles your server. Those two pipelines can quietly disagree about path aliases, environment variables, and module resolution, leading to bugs that only show up on one side. Vavite means one vite.config.ts, one plugin set, one shared notion of how modules resolve.

Second, and this is the headline feature, watchers like nodemon restart the entire Node process on every change. That is slow, and it throws away all your in-memory state: database connection pools, open websocket connections, warm caches. Vavite gives you Vite-style hot replacement on the server, swapping individual modules while the process keeps running.

Third, Vavite also handles the trickier case where your server code itself needs Vite's transforms, such as importing a .tsx component server-side for SSR or wanting import.meta.hot to work in your backend. Historically that required hand-wiring ssrLoadModule against a dev server instance. Vavite packages all of that so your server entry simply runs under vite dev.

Getting It Installed

Vavite is a single package, and the plugin lives inside it. Install it alongside Vite.

npm install --save-dev vavite vite

Or with yarn:

yarn add --dev vavite vite

A note on requirements: the current version 7 needs Node 22 or newer and Vite in the ~7.3 || 8 range. If you are on an older Node, version 6 supports Node 20. The single runtime dependency is a small HTTP proxy used only in one of the two entry modes, so the footprint stays minimal.

Wiring Up Your First Server

Vavite plugs into Vite through your config file. The one detail people forget is appType: "custom", which tells Vite to stop trying to serve an index.html for the / route, because your server is now in charge of responding.

// vite.config.ts
import { defineConfig } from "vite";
import { vavite } from "vavite";

export default defineConfig({
  appType: "custom",
  plugins: [vavite()],
});

By default Vavite looks for your server entry at /src/entry.server.{js,ts,jsx,tsx}. The entry's default export is where the interesting stuff happens, and the mental model for it rests on two entry types.

The first and default type is runnable-handler. Here your entry default-exports a plain Node http-compatible request handler with the familiar (req, res, next) shape. In development, Vavite imports that handler and runs it as middleware on Vite's own dev server, so it sits right in the request pipeline. In production, you start the actual server yourself. Almost every Node framework, Express, Fastify, Koa, Hapi, Nest, can hand you a compatible handler.

Here is a complete, framework-free handler entry that shows the whole lifecycle:

// src/entry.server.ts
/// <reference types="vite/client" />
/// <reference types="vavite/types" />
import {
  createServer,
  type IncomingMessage,
  type ServerResponse,
} from "node:http";

// The default export is the dev handler, used as Vite middleware.
export default function handler(req: IncomingMessage, res: ServerResponse) {
  if (req.url === "/") {
    res
      .setHeader("Content-Type", "text/html; charset=utf-8")
      .end("<h1>Hello from a Vite-powered server!</h1>");
  } else {
    res.statusCode = 404;
    res.end("Not found");
  }
}

// In a production build, start the server explicitly.
if (import.meta.env.COMMAND === "build") {
  createServer(handler).listen(3000, () =>
    console.log("Server listening on http://localhost:3000"),
  );
}

// Opt in to server-side hot module replacement.
if (import.meta.hot) import.meta.hot.accept();

Run vite dev and your handler is live as middleware with hot reloading. Edit the response string, hit save, and the module swaps without a restart. The import.meta.env.COMMAND check is how the same file knows whether it is being served in dev or run from a production build, so you only spin up a real http server when there is no Vite dev server to lean on.

The Other Entry Type: Running Your Own Server

The second entry type is runnable-server, and you reach for it when you need to control how the HTTP server is created in dev, or when you are using a server that is not Node's built-in http, such as Bun.serve. Here your entry starts its own server on a separate port, and Vavite proxies incoming dev requests to it.

// vite.config.ts
import { defineConfig } from "vite";
import { vavite } from "vavite";

export default defineConfig({
  appType: "custom",
  plugins: [
    vavite({
      entries: [
        {
          entry: "/src/entry.server",
          type: "runnable-server",
          proxyOptions: { target: "http://localhost:3000" },
        },
      ],
    }),
  ],
});

Handler mode is more efficient and more battle-tested, so prefer it when you can. Server mode exists for the cases where you genuinely need to own the server creation, and the proxy makes that work transparently.

Entries can also be chained and ordered. Each entry takes an order of "pre" or "post", controlling whether it runs before or after Vite's own middlewares. A "pre" entry bypasses Vite's client features and is ideal for a pure backend, while a "post" entry sits behind Vite's asset pipeline for SSR. Non-final handler entries forward requests they do not handle by calling next(), which is exactly how requests like /@vite/client get passed back to Vite to keep client HMR working even in a backend-first setup.

Keeping Resources Alive Across Reloads

This is the feature that genuinely sets Vavite apart, and it is impossible with a process-restarting watcher. When a server module hot-reloads, Vavite calls both the dispose and prune handlers, not just on a clean hot-accept but on restart too. That gives you a real chance to clean up resources gracefully, and even to hand expensive ones forward across reloads.

// src/entry.server.ts
import { createPool } from "./db";

// Reuse the pool across hot reloads instead of reconnecting every save.
const pool =
  import.meta.hot?.data.pool ?? createPool({ max: 10 });

if (import.meta.hot) {
  import.meta.hot.data.pool = pool;

  import.meta.hot.dispose(() => {
    // Only tear down when the process is actually going away,
    // not on every module swap, so the pool survives edits.
  });
}

export default function handler(req, res) {
  // ...use pool to answer requests...
}

The pattern is to stash the resource on import.meta.hot.data, which persists across module swaps, and to close it only in the dispose handler when the process is truly shutting down. A database pool, a websocket server, an in-memory cache: all of these can now survive an edit-save-reload cycle that would otherwise reset them. With nodemon you simply cannot do this, because the whole process dies on every change. This is why Vavite is such a good fit for long-lived-connection servers and websocket backends.

Client and Server, One Build

For full SSR you usually want two build outputs, a client bundle and a server bundle. Vavite leans on Vite's modern Environment API to express both in one config.

// vite.config.ts
import { defineConfig } from "vite";
import { vavite } from "vavite";

export default defineConfig({
  appType: "custom",
  environments: {
    client: {
      build: {
        manifest: true,
        outDir: "dist/client",
        rollupOptions: {
          input: { "entry.client": "/src/entry.client.tsx" },
        },
      },
    },
    ssr: {
      build: { outDir: "dist/server" },
    },
  },
  plugins: [vavite()],
});

Build both with vite build --app, or orchestrate the ordering yourself through builder.buildApp if the server build needs the client manifest first. In production there is no Vite middleware in the picture at all, so you serve the static files from dist/client with your own static-file middleware and let your server bundle handle the rest. If you need to reach the running dev server from inside your code, Vavite exposes it through a virtual module, import viteDevServer from "vavite:vite-dev-server", which simply resolves to undefined in production.

A Library That Shrinks On Purpose

There is a small history lesson worth knowing if you ever read older Vavite tutorials. Versions 1 through 5 were not a single plugin but a whole suite of scoped packages: @vavite/connect for running a handler as middleware, @vavite/reloader for hot-reloading a standalone server, @vavite/multibuild for running sequential client-then-server builds, @vavite/expose-vite-dev-server, and more, all driven by a vavite CLI command.

Then Vite 6 shipped its native Environment API, and version 6 of Vavite was a complete rewrite that collapsed every one of those packages into the single vavite plugin. Each piece of Vavite that once worked around a Vite limitation got absorbed the moment Vite itself grew the feature: multi-environment builds, native server-side HMR, dev-server access. Version 7, the current release, continues that lean philosophy.

It is a refreshingly honest arc, a maintainer deleting their own code as the platform catches up. The practical takeaway is to watch out when reading old guides: anything mentioning @vavite/connect, the handlerEntry option, the vavite CLI, or the old vavite/vite-dev-server import is pre-v6 and no longer how things work. The modern API is the single plugin and the entries option you have seen throughout this article.

Where Vavite Fits

Vavite occupies a comfortable middle ground in the Vite server ecosystem. Below it sits low-level plumbing like vite-node and Vite's raw Environment API, which run server code through Vite but leave the dev-server-as-middleware, HMR, and multi-build workflow for you to assemble. Above it sit full-blown server frameworks like Nitro and SSR meta-frameworks like Vike, which are heavier and far more opinionated. Vavite is the thin, framework-agnostic layer that makes your own server a first-class Vite citizen without committing you to anyone's framework.

That makes it a natural fit for a handful of jobs: building a custom SSR server for React, Solid, or similar, where you want one pipeline for both ends; running a pure TypeScript API server on Express, Fastify, Koa, or Nest with real backend HMR instead of ts-node and nodemon; serving Bun apps through the proxy mode; and powering websocket servers that benefit from graceful resource disposal on reload. It is also the quiet foundation under SSR meta-frameworks, including the author's own Rakkas, which many people use without ever knowing Vavite is down there.

If you have been running two toolchains just to develop a Vite-powered app with a backend, Vavite collapses that into one and hands you hot reloading on the server as a bonus. Va vite indeed.