Creating a Custom Vite Plugin with TypeScript Support to Automatically ZIP Build Artifacts at Build Time

Aug 1, 2025

#vite #frontend-infra #nodejs #typescript

This blog walks through an example of creating a custom Vite plugin that automatically zips build artifacts at build time. It also includes TypeScript support for full type safety.

build demo

Implementation

We first initialize a Node.js project and install TypeScript along with the Node type definitions.

mkdir vite-plugin-zip
cd vite-plugin-zip
npm init -y
npm i -D typescript @types/node

Next, we need to install archiver, a package used to generate archives in the plugin.

We’ll save it as a devDependency because the plugin is only used during the build process, either on developers’ machines or in CI pipelines, and it should not be included in production builds.

npm i -D archiver

We also need a tsconfig.json file to inform the tsc compiler of the input and output dirs for the project, and to specify whether we are building the project as an ES module or a CJS module.

// tsconfig.json
{
	"compilerOptions": {
		"target": "ES2020",
		"module": "ESNext", // building for esm, enabling import/export syntax
		"lib": ["ESNext"],
		"moduleResolution": "Node",
		"esModuleInterop": true,
		"outDir": "./plugins-dist", // output
		"rootDir": "./plugins", // input
		"declaration": true,
		"skipLibCheck": true
	},
	"include": ["plugins"]
}

Next, we write our plugin code in vite-plugin-zip.ts.

We use the closeBundle hook from Rollup (which is used by Vite under the hood for its plugin system) to run the ZIP logic during the output generation stage.

//vite-plugin-zip.ts
import fs from "fs";
import path from "path";
import archiver from "archiver";

interface ZipPluginOptions {
	outputName?: string;
	input?: string;
}

export default function vitePluginZip(options: ZipPluginOptions = {}) {
	const { outputName = "dist.zip", input = "dist" } = options;

	return {
		name: "vite-plugin-zip",
		closeBundle: async () => {
			const outputPath = path.resolve(process.cwd(), outputName);
			const inputPath = path.resolve(process.cwd(), input);

			if (!fs.existsSync(inputPath)) {
				console.warn(
					`[vite-plugin-zip] Input folder "${input}" does not exist.`
				);
				return;
			}

			const output = fs.createWriteStream(outputPath);
			const archive = archiver("zip", { zlib: { level: 9 } });

			return new Promise<void>((resolve, reject) => {
				output.on("close", () => {
					console.log(
						`[vite-plugin-zip] Created ${outputName} (${archive.pointer()} total bytes)`
					);
					resolve();
				});

				archive.on("error", (err) => reject(err));

				archive.pipe(output);
				archive.directory(inputPath, false);
				archive.finalize();
			});
		},
	};
}

We may also add some log info.

output.on("close", () => {
    const sizeInKB = (archive.pointer() / 1024).toFixed(2);
    console.log(
        `\x1b[32m[vite-plugin-zip]\x1b[0m Created "${outputName}" (${sizeInKB} KB)`
    );
    console.log(
        `\x1b[32m[vite-plugin-zip]\x1b[0m Zip file is ready at: ${outputPath}`
    );
    resolve();
});

Finally, we can build the plugin by running

npx tsc

Which compiles(transpiles) plugins/vite-plugin-zip.ts into plugins-dist/vite-plugin-zip.js and plugins-dist/vite-plugin.d.ts.

Testing

Start with a freshly created Vite project, created using

pnpm create vite

We then edit vite.config.ts, do a local relative import of our newly created plugin, and optionally pass needed params into the plugin.

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import vitePluginZip from "../vite-plugin-zip/plugins-dist/vite-plugin-zip.js";

export default defineConfig({
	plugins: [
		react(),
		vitePluginZip({
			outputName: "build.zip", // optional
			input: "dist", // optional
		}),
	],
});

To build the test project, run

pnpm run build

We can then see the log info of our custom Vite plugin

log pic

and the generated ZIP file.

file pic