Library / SDK
cyco130/vavite avatar
cyco130/vavite

vavite uses Vite itself to transpile the server side, not just the client

Develop server-side applications with Vite

566 stars15 forksTypeScriptMIT

At a glance

What is it?
vavite is an MIT licensed Vite plugin that turns Vite's dev server into a Node.js application server, so server-side TypeScript and JSX go through the same pipeline as the client code instead of a second toolchain. It suits a Node.js developer building a server-rendered app, and its server entry mode proxies requests, which the documentation itself calls less efficient and less well tested than the default.
Who is it for?
vavite fits a Node.js developer whose server-side code is TypeScript or JSX and who wants one toolchain rather than Vite for the client and a separate runner for the server, since the plugin removes the need for a second transpilation setup in development.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository received new commits within the last day.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem is having two toolchains for one application

vavite is a Vite plugin for developing and building server-side applications, using Vite as the transpiler and bundler. The problem it addresses is described precisely in the project documentation.

Vite, despite being mainly a frontend tool, has support for transpiling server-side code, and the feature is intended for building server-side rendering applications. But it can be put to use for server-side applications that are not necessarily related to SSR, and vavite does that while also simplifying the SSR workflow.

The specific pain is this. Vite's official SSR guide describes a workflow where Vite's development server is used as a middleware function in a server application made with a Connect compatible Node.js framework such as Express. If your server-side code needs transpilation, for example for TypeScript or JSX, you are required to use another set of tools, say ts-node and nodemon, for development and building.

So a full-stack TypeScript app ends up with Vite for the browser and a separate runner for the server, and the two halves of one application are compiled by different tools. vavite removes the second toolchain by making Vite transpile the server code too.

The user is therefore a Node.js developer who wants one config file, one dependency set and one build for both sides of an application.

Three steps to a running server

The getting started path is short, and the documentation says the easiest way in is to use one of the examples as a template.

First, install vavite as a development dependency.

sh
   npm install --save-dev vavite

Then add the plugin to the Vite config.

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

   export default defineConfig({
     appType: "custom", // Prevent Vite from serving index.html for the / route
     plugins: [
       vavite({
         // Optional configuration options go here
       }),
     ],
   });

The appType line is the one to understand. Setting it to custom stops Vite from serving its own index.html for the root route, because in this setup the application, not Vite, is responsible for what a request to / returns.

Third, create a handler or server entry. vavite looks for /src/entry.server.{js,ts,jsx,tsx} by default, so the file name and location are a convention you get for free rather than something you configure.

The repository has an examples/ directory with a folder per framework or scenario: bun-server, express-server, express, fastify, hapi, koa, nestjs, resource-cleanup, simple-standalone, ssr-react-express and ws. The claim in the documentation is that a compatible handler can be obtained from most Node.js server frameworks, and the examples exist to show how.

Handler entries are the default, and they are the direct path

vavite recognises two entry types, given through the type property of the entries you provide via the entries configuration option: handler entries, `runnable-handler`, which is the default, and server entries, `runnable-server`.

Handler entries are expected to export a node:http compatible request handler, and during development, vavite imports your entry and calls the exported handler for each incoming request. For production, you need to explicitly start the server.

ts
// entry.server.ts

// Alternatively, you can add these to the `types` option of your tsconfig.json
/// <reference types="vite/client" />
/// <reference types="vavite/types" />

import {
  createServer,
  type IncomingMessage,
  type ServerResponse,
} from "node:http";

// Default export a handler for dev
export default function handler(req: IncomingMessage, res: ServerResponse) {
  if (req.url === "/") {
    res
      .setHeader("Content-Type", "text/html; charset=utf-8")
      .end("<h1>Hello from standalone!</h1>");
  } else {
    res.statusCode = 404;
    res.end("Not found");
  }
}

// Start the standalone server in production mode
if (import.meta.env.COMMAND === "build") {
  createServer(handler).listen(3000, () => {
    console.log("Server is listening on http://localhost:3000");
  });
}

// Enable hot module replacement
if (import.meta.hot) {
  import.meta.hot.accept();
}

Three details in that file carry the design. The `import.meta.env.COMMAND === "build"` check is what makes the same file work in both modes, serving as middleware in development and starting a real listener in production. The `import.meta.hot.accept()` call is how the server code gets hot reloaded, and the tsconfig reference to vavite/types is what gives the editor the types for that API.

The mechanism is direct function calls, which is the fast path and the better tested one.

Server entries proxy, which is the less good path

The second entry type exists for a specific reason, and the documentation is honest about its cost.

Server entries are expected to start an HTTP server on a separate port from the Vite dev server and process incoming requests. For development, vavite will proxy incoming requests to that server. For production, your server will be the one directly receiving incoming requests.

The reason to use it is stated plainly: proxying is less efficient than direct function calls and is less well tested. But it is useful when you need control over server creation even in development, or when you want to use an HTTP serving library that is not compatible with node:http, like Bun.serve.

Because this is not the default entry type, you have to specify the entry type and the address explicitly in the Vite config.

ts
// vite.config.ts

// Alternatively, you can add these to the `types` option of your tsconfig.json
/// <reference types="vite/client" />
/// <reference types="vavite/types" />

import { defineConfig } from "vite";
import { vavite } from "vavite";

export default defineConfig({
  appType: "custom", // Prevent Vite from serving index.html for the / route
  plugins: [
    vavite({
      entries: [
        {
          entry: "/src/entry.server",
          type: "runnable-server",
          proxyOptions: {
            target: "http://localhost:3000",
          },
        },
      ],
    }),
  ],
});

And the entry itself starts its own listener.

ts
// entry.server.ts
import { createServer } from "node:http";

const server = createServer((req, res) => {
  if (req.url === "/") {
    res
      .setHeader("Content-Type", "text/html; charset=utf-8")
      .end("<h1>Hello from standalone!</h1>");
  } else {
    res.statusCode = 404;
    res.end("Not found");
  }
}).listen(3000, () => {
  console.log("Server is listening on http://localhost:3000");
});

if (import.meta.hot) {
  // Enable hot module replacement
  import.meta.hot.accept();

  import.meta.hot.dispose(() => {
    // Close the old server before starting a new one
    server.close();
  });
}

The dispose callback is the part to copy. When the module is replaced, the old listener has to be closed, or the next reload cannot bind the same port.

Entry order decides who sees the request first

Each entry has an optional order property which can be either `pre` or `post`, and it determines whether the entry will be placed before or after Vite's own middlewares.

The default is inferred rather than fixed. If not specified, vavite will try to automatically determine the order based on whether you have configured a client entry or not, and the default order will be `post` if you have a client entry, otherwise `pre`.

That inference is the interesting part. With a client entry, your server runs after Vite's middlewares so that Vite's asset transformation pipeline has already handled the request, which is what an SSR setup needs. Without a client entry, there is nothing for Vite's middlewares to do for the request, so your handler runs first.

The documentation gives the use case for each. `pre` entries are useful for server-only setups where you do not need Vite's client-side features, or when you need some processing before Vite's middlewares. `post` entries are useful for SSR setups where you want Vite's asset transformation pipeline to run first.

You can also mark an entry with final: true to stop the request processing chain, and vavite will mark an entry as final by default if it is the last entry in the chain, respecting the pre and post order.

So the chain is a genuine pipeline, and the ordering rules are the mechanism you use to place your handler in it.

A pnpm workspace with a plugin package and a test matrix

The repository is a pnpm workspace whose root package is named vavite-workspace-root and is private, with the scripts delegating to the packages directory.

json
  "name": "vavite-workspace-root",
  "private": "true",
  "scripts": {
    "dev": "pnpm -r --parallel --filter \"./packages/*\" run dev",
    "build": "pnpm -r --filter \"./packages/*\" run build",
    "prepare": "husky",
    "precommit": "lint-staged",
    "format": "prettier --write .",
    "ci": "pnpm -r --stream run ci",
    "test": "pnpm run /^test:/",
    "test:packages": "pnpm -r --stream run test",
    "test:format": "prettier --check ."

The test script is worth noting, because it uses a regular expression to match script names rather than a fixed list, so any script named test:something is picked up. There are two such scripts, test:packages which streams the per package tests and test:format which runs prettier in check mode.

The workspace root is also where the shared tooling lives: husky for the git hooks, lint-staged for the precommit script, and prettier for formatting, with a .prettierignore and a .prettierrc alongside. There is a ci/ directory, a .husky/ directory, and an AGENTS.md and a CLAUDE.md at the root.

The releases are 7.0.1 on 2026-06-09, 7.0.0 on 2026-05-14 and 6.2.1 on 2026-04-14, and the last push was on 2026-10-20 with the repository not archived. The licence is MIT, with the LICENSE file at the root.

Where vavite is the wrong choice

There are honest limits, and the project states one of them itself.

The first is the proxying entry type. The documentation says proxying is less efficient than direct function calls and is less well tested. Choosing runnable-server therefore trades the well trodden path for one the author describes as under tested, and the reason to do it, control over server creation or a non node:http serving library, is a fairly specific need.

The second is that this is a Vite plugin, so Vite is a hard dependency of your server development setup. A Node.js service with no client side at all, or a team that has moved to a different bundler, gains nothing here, and the plugin's value is entirely in replacing a second toolchain that only exists because the project has a Vite client already.

The third is the shape of the answer to the problem. The workflow described by the Vite SSR guide, a dev server used as middleware inside a Connect compatible application, is not unusual, and the friction it creates is real but bounded. A project with a handful of server routes may find that plain tsx or a modern Node.js type stripping setup is less machinery than adding a plugin to the build config, and a project that has to keep both a client bundle and a server bundle in step will get more out of vavite.

One more limit is worth naming because the documentation is brief on it. The repository layout includes a resource-cleanup example, which tells you that shutting down server resources cleanly during reloads is a case the project has had to handle, and the dispose hook in the server entry example is the visible part of that.

Editorial conclusion

vavite fits a Node.js developer whose server-side code is TypeScript or JSX and who wants one toolchain rather than Vite for the client and a separate runner for the server, since the plugin removes the need for a second transpilation setup in development. It does not fit a project that has no Vite dev server in the loop, and it does not fit a team that needs the proxying entry type, because the documentation says proxying is less efficient and less well tested than direct function calls. Before adopting it, start from one of the framework examples rather than from scratch, and decide between the handler and server entry types early, since the default handler mode is the better tested path.

Frequently asked questions

What does the vavite plugin do for a Node.js server?

It makes Vite's dev server serve your server-side code, so the same Vite transpiler handles the client and the server instead of a separate runner such as ts-node. Entries are exported as node:http compatible handlers and are called directly for each request in development.

What is the difference between a handler entry and a server entry in vavite?

A handler entry, the default type runnable-handler, exports a node:http compatible handler that vavite calls directly for each request. A server entry, runnable-server, starts its own HTTP server on a separate port and vavite proxies requests to it, which the documentation says is less efficient and less well tested but gives you control over server creation.

How do I set up vavite in a new project?

Install it with npm install --save-dev vavite, add the vavite plugin to your Vite config with appType set to custom, and create a handler or server entry. vavite looks for /src/entry.server.{js,ts,jsx,tsx} by default, and the project recommends starting from one of its examples.

How does vavite decide the order of entries?

Each entry can set order to pre or post, which places it before or after Vite's own middlewares. If it is not specified, vavite infers the order from whether a client entry is configured, using post when there is one and pre when there is not.

Official sources

  1. cyco130/vavite on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/cyco130-vavite.svg)](https://hysenlabs.com/projects/cyco130-vavite)

Was this useful?