DEV Community

Cover image for Build Google Apps Script with Any Bundler: Vite, Rollup, esbuild, webpack, and Bun
Tetsuya Wakita
Tetsuya Wakita

Posted on

Build Google Apps Script with Any Bundler: Vite, Rollup, esbuild, webpack, and Bun

Google Apps Script projects shouldn’t be tied to one bundler.

So I rebuilt my Vite-only plugin into a universal plugin for Vite, Rollup, webpack, esbuild, and Bun — and added a scaffolding CLI on top.


The problem with modern bundlers in Google Apps Script

If you’ve ever deployed a GAS project only to find that doGet was silently removed by tree-shaking — or that export keywords broke the Apps Script runtime — you know the pain.

Google Apps Script has a slightly awkward relationship with modern JavaScript tooling.

Bundlers like Vite, Rollup, webpack, and esbuild are great at optimizing modules. But GAS expects certain functions — doGet, doPost, onOpen, installable triggers, menu handlers — to exist in the global scope with stable names.

That creates a few recurring problems:

  • export is convenient in source code, but GAS doesn’t want it in the final bundle
  • tree-shaking can remove entry points that are only referenced by the Apps Script runtime
  • appsscript.json has to be copied into the output alongside the bundle

Here’s what the plugin actually does to your output:

// Your source:
export function doGet(e) {
  return HtmlService.createHtmlOutput("Hello");
}

// Without plugin — bundler may wrap, rename, or tree-shake:
// doGet is gone or unreachable

// With @gas-plugin/unplugin — export stripped, global preserved:
function doGet(e) {
  return HtmlService.createHtmlOutput("Hello");
}
Enter fullscreen mode Exit fullscreen mode

In my previous post, I introduced gas-vite-plugin, a small Vite plugin that handled exactly that.

It worked well — but only for Vite.

If your team used Rollup, webpack, or esbuild, you had to solve the same problem a different way.

So I rebuilt it as a universal plugin.


What changed

The project is now @gas-plugin — a monorepo with two packages:

Package What it does
@gas-plugin/unplugin Universal bundler plugin for Vite, Rollup, webpack, esbuild, and Bun
@gas-plugin/cli Scaffolding CLI for new GAS projects

The original gas-vite-plugin still exists as a deprecated compatibility package, but the main path going forward is @gas-plugin/unplugin.


Why I rebuilt it with unplugin

I could have maintained separate plugins for each bundler.

That would have meant duplicating the same logic across multiple packages: transform exported entry points, preserve GAS globals, and copy the manifest into the output directory.

Instead, I used unplugin, which lets you implement the plugin once and expose adapters for multiple bundlers. For this kind of narrowly focused build-time behavior, that’s a much better fit than maintaining five independent implementations.

The result is simple:

  • same plugin behavior
  • same options
  • different import path per bundler

That means the GAS-specific parts stay consistent even if your build tool changes.


@gas-plugin/unplugin: one plugin, any bundler

Here’s the same plugin working across different bundlers.

Vite

import gasPlugin from "@gas-plugin/unplugin/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [gasPlugin()],
  build: {
    lib: {
      entry: "src/main.ts",
      formats: ["es"],
      fileName: () => "Code.js",
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Rollup

import gasPlugin from "@gas-plugin/unplugin/rollup";

export default {
  input: "src/main.ts",
  output: { dir: "dist", format: "es" },
  plugins: [gasPlugin()],
};
Enter fullscreen mode Exit fullscreen mode

esbuild

import gasPlugin from "@gas-plugin/unplugin/esbuild";
import { build } from "esbuild";

await build({
  entryPoints: ["src/main.ts"],
  outdir: "dist",
  bundle: true,
  format: "esm",
  plugins: [gasPlugin()],
});
Enter fullscreen mode Exit fullscreen mode

webpack

import gasPlugin from "@gas-plugin/unplugin/webpack";

export default {
  entry: "./src/main.ts",
  output: { path: new URL("./dist", import.meta.url).pathname },
  plugins: [gasPlugin()],
};
Enter fullscreen mode Exit fullscreen mode

Bun

import gasPlugin from "@gas-plugin/unplugin/bun";

await Bun.build({
  entrypoints: ["src/main.ts"],
  outdir: "dist",
  plugins: [gasPlugin()],
});
Enter fullscreen mode Exit fullscreen mode

The options are the same everywhere:

gasPlugin({
  manifest: "appsscript.json",
  include: ["src/**/*.html"],
  globals: ["processData"],
  autoGlobals: true,
});
Enter fullscreen mode Exit fullscreen mode

How autoGlobals and globals work

By default, bundlers may tree-shake or rename your GAS entry points like doGet or onOpen — because nothing in your code imports them.

  • autoGlobals: true scans your source for exported functions and automatically exposes them as global declarations in the output. This is the simplest way to make sure your GAS entry points survive the build.
  • globals: ["processData"] lets you manually specify additional functions that should be preserved as globals — useful for installable triggers or menu callbacks that aren’t exported from the entry point.

You can use both together: autoGlobals handles the common case, and globals covers edge cases.

That consistency is the main goal: you shouldn’t have to rethink your GAS build setup just because you prefer one bundler over another.


@gas-plugin/cli: scaffold a project in seconds

I also added a CLI so you can start a new project without wiring everything manually.

npx @gas-plugin/cli create
Enter fullscreen mode Exit fullscreen mode

You’ll get interactive prompts for:

  • project name
  • template
  • bundler
  • optional clasp configuration
  • optional Script ID

Example:

◆  Project name
│  my-gas-app
│
◆  Template
│  ● basic — Spreadsheet automation (onOpen, triggers)
│  ○ webapp — Web app (doGet/doPost + HTML client)
│
◆  Bundler
│  ● vite
│  ○ rollup
│  ○ esbuild
│  ○ webpack
│  ○ bun
│
◆  Include clasp configuration?
│  Yes
│
◆  Script ID (optional)
│  1BxTjDm...
Enter fullscreen mode Exit fullscreen mode

And if you prefer automation-friendly setup, you can skip prompts entirely:

npx @gas-plugin/cli create my-gas-app \
  --template webapp \
  --bundler vite \
  --clasp \
  --script-id YOUR_SCRIPT_ID
Enter fullscreen mode Exit fullscreen mode

The generated project is intentionally minimal:

my-gas-app/
├── src/
│   ├── index.ts
│   ├── utils.ts
│   └── client.html       # webapp template only
├── package.json
├── appsscript.json
├── tsconfig.json
├── biome.json
├── vite.config.ts        # or rollup/esbuild/webpack config
├── .clasp.json           # when --clasp
├── .claspignore
└── .gitignore
Enter fullscreen mode Exit fullscreen mode

Then:

npm run build
npx clasp push
Enter fullscreen mode Exit fullscreen mode

That gives you the basics you actually need, without forcing a larger opinionated toolchain on every project.


Why this exists when google/aside already exists

Google already provides @google/aside, and it’s a legitimate option.

So this project is not trying to replace it outright.

The difference is scope.

google/aside is a batteries-included starter

It gives you a more opinionated setup out of the box.

@gas-plugin is a smaller, composable alternative

It focuses on two things:

  • making bundled output work cleanly for GAS
  • letting you choose your own bundler and surrounding toolchain

Here’s the practical difference:

google/aside @gas-plugin
Bundler choice Primarily Rollup-based setup Vite, Rollup, esbuild, webpack, Bun
export handling You work within the template’s conventions export is stripped at build time for GAS output
Tree-shaking protection Side-effect import pattern autoGlobals can preserve exported entry points automatically
Scaffolding style More batteries included Minimal scaffold, bring your own tools
Templates Opinionated starter basic and webapp

So the real distinction is:

  • choose google/aside if you want a fuller default setup
  • choose @gas-plugin if you want a lighter, bundler-agnostic foundation

Migration from gas-vite-plugin

If you’re already using the old package, migration is intentionally small:

- import gasPlugin from "gas-vite-plugin";
+ import gasPlugin from "@gas-plugin/unplugin/vite";
Enter fullscreen mode Exit fullscreen mode
npm uninstall gas-vite-plugin
npm install -D @gas-plugin/unplugin
Enter fullscreen mode Exit fullscreen mode

Same idea, same behavior — just no longer tied to Vite alone.


Why I think this matters

GAS is still incredibly useful for internal tools, automations, lightweight web apps, and spreadsheet-driven workflows.

But the tooling ecosystem has often pushed developers into one of two extremes:

  • stay close to vanilla Apps Script and give up modern build ergonomics
  • adopt a very specific template and toolchain

I wanted something in between:

  • modern TypeScript workflow
  • support for the bundler you already use
  • minimal GAS-specific friction
  • no unnecessary lock-in

That’s what @gas-plugin is trying to be.

If you’ve tried it, I’d love to hear which bundler you’re using and whether anything felt off. Issues and PRs are welcome on GitHub.


Links

Top comments (0)