Package Exports
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (create-gas-app) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
create-gas-app
The modern CLI to build Google Apps Script add-ons with React, Vue, Svelte, SolidJS, or plain HTML + CSS + TypeScript.
Write real TypeScript, get live reload inside GAS dialogs, call server functions with full type inference — then ship everything as the two files GAS actually understands.
npx create-gas-app@latest
# or
npx create-gas-app@latest my-sheets-addonPrerequisites
- Node.js ≥ 18
- mkcert (optional, for local dev server) — generates trusted local HTTPS certs. Install instructions
Create a project
Running the CLI starts an interactive prompt:
create-gas-app — Google Apps Script, your way
What is your project named?
› my-gas-app
What type of Google Apps Script project?
● Sheets Add-on
○ Docs Add-on
○ Forms Add-on
○ Standalone Script
Which frontend framework?
● React (TypeScript + SWC)
○ Vue (Vue 3 + TypeScript)
○ Svelte (Svelte 5 + TypeScript)
○ SolidJS (SolidJS + TypeScript)
○ Vanilla (Plain HTML + CSS + TypeScript, no framework)
Select addons (space to toggle, enter to confirm):
◼ Tailwind CSS v4
◼ shadcn/ui ← only shown when React is selected
◻ Commitlint + Lefthook
◻ ESLint
Which package manager?
● bun (detected) ○ pnpm ○ npm ○ yarn
Setup options:
◼ Install dependencies
◼ Initialize git repositoryThe package manager prompt pre-selects whichever manager was used to run the CLI (detected from lockfiles or npm_config_user_agent).
Note: All command examples below use
npm run. Substitutebun run,pnpm run, oryarndepending on what you chose at scaffold time.
Getting started
All project types share the same Vite monorepo structure and the same workflow. Pick your project type during scaffolding — the rest is identical.
Generated structure
my-gas-app/
├── .vscode/
│ └── settings.json ← IDE settings (Prettier, Tailwind, TypeScript)
├── apps/
│ └── my-gas-app/
│ ├── env.ts ← Runtime env — gitignored
│ ├── env.example.ts ← Template for env.ts (committed)
│ └── dialogs/
│ ├── sidebar/
│ │ ├── index.html ← importmap + entry script (no bundled deps)
│ │ └── src/
│ │ ├── main.ts(x)
│ │ └── App.ts(x)
│ └── about/
│ ├── index.html
│ └── src/
│ ├── main.ts(x)
│ └── App.ts(x)
├── packages/
│ ├── server/
│ │ └── src/
│ │ ├── index.ts ← Export server functions here → auto-typed on client
│ │ ├── ui.ts ← onOpen(), openSidebar(), openAboutDialog()
│ │ └── env.ts ← Server-side secrets — gitignored
│ ├── shared/
│ │ └── src/
│ │ ├── utils/server.ts ← Typed serverFunctions proxy
│ │ └── styles/global.css ← Global styles shared by all dialogs
│ └── ui/
│ └── src/
│ └── index.ts ← Shared component library
├── vite.config.ts
├── appsscript.json ← GAS manifest with OAuth scopes
├── README.md ← Project-specific readme with dev workflow
└── package.json ← Workspaces + all scriptsStep 1 — Connect to Google
Authenticate once with your Google account:
npm run clasp:loginThen create a new GAS project and link it to your repo:
npm run clasp:createThis writes .clasp.json with your script ID. Run it once — all future pushes go to the same project.
To link an existing GAS project instead, get the script ID from the Apps Script URL (https://script.google.com/d/<SCRIPT_ID>/edit) and create .clasp.json manually:
{ "scriptId": "<YOUR_SCRIPT_ID>", "rootDir": "./dist" }Step 2 — Set up local HTTPS
GAS only allows iframes from HTTPS origins. Generate a trusted local cert once:
# Requires mkcert: https://github.com/FiloSottile/mkcert
npm run setup:certsStep 3 — Start the dev server
npm run devThis pushes lightweight iframe wrappers to GAS, then starts Vite at https://localhost:3000. Open your Google Sheet / Doc / Form → Extensions → My App → Open — the sidebar loads your local Vite app with full hot reload.
google.script.run calls are proxied through a postMessage bridge so real server functions execute in GAS while your UI hot-reloads locally.
Step 4 — Deploy
npm run deployBuilds all dialogs to single inlined HTML files, builds the server to a single ES bundle (exports stripped for GAS compatibility), and pushes to GAS.
Frameworks
React
TypeScript + SWC. Includes JSX transform, React 19, and optionally shadcn/ui.
Vue
Vue 3 + TypeScript via @vitejs/plugin-vue.
Svelte
Svelte 5 + TypeScript via @sveltejs/vite-plugin-svelte.
SolidJS
SolidJS + TypeScript via vite-plugin-solid.
Vanilla
No framework dependencies. Uses a class-based App pattern with raw DOM manipulation and TypeScript. A good choice when bundle size matters most or when you don't need a reactive framework.
// apps/my-gas-app/dialogs/sidebar/src/App.ts
export class App {
private root: HTMLElement;
constructor(root: HTMLElement) {
this.root = root;
this.render();
}
private render(): void {
this.root.innerHTML = `<h1>Hello from GAS!</h1>`;
}
}Project types
All project types share the same structure and workflow. The differences are which GAS service is used server-side and what starter functions are generated.
Sheets Add-on
Extends Google Sheets. Uses SpreadsheetApp.getUi() for the Extensions menu. The generated starter functions:
// Returns spreadsheet name, active sheet name, and row count
export const getSpreadsheetInfo = (): {
id: string; name: string; activeSheet: string; rowCount: number;
} => { ... };
// Returns headers + first N rows of a sheet
export const getSheetData = (sheetName?: string, maxRows = 20): {
headers: string[]; rows: string[][];
} => { ... };Docs Add-on
Extends Google Docs. Uses DocumentApp.getUi() for the Extensions menu. The generated starter function:
export const getDocumentInfo = (): { id: string; name: string } => {
const doc = DocumentApp.getActiveDocument();
return { id: doc.getId(), name: doc.getName() };
};Forms Add-on
Extends the Google Forms editor — adds sidebars, dialogs, and menu items to the form editing interface. It does not modify the form that respondents see. Uses FormApp.getUi() for the Extensions menu.
The generated starter function:
export const getFormInfo = (): { id: string; title: string } => {
const form = FormApp.getActiveForm();
return { id: form.getId(), title: form.getTitle() };
};Forms add-ons also support installable triggers. For example, running a function every time a respondent submits the form:
export const onFormSubmit = (e: GoogleAppsScript.Events.FormsOnFormSubmit): void => {
const response = e.response;
// process response...
};Standalone Script
A standalone script has no container. It is deployed as a web app and responds to HTTP requests via doGet and doPost. There is no Extensions menu and no onOpen trigger.
export const doGet = (_e: GoogleAppsScript.Events.DoGet) => {
return HtmlService.createHtmlOutputFromFile("sidebar").setTitle("My App");
};
export const doPost = (_e: GoogleAppsScript.Events.DoPost) => {
return ContentService.createTextOutput(JSON.stringify({ status: "ok" }))
.setMimeType(ContentService.MimeType.JSON);
};Deploy via Deploy → New deployment → Web app in the Apps Script editor.
OAuth scopes
Google Apps Script requires explicit OAuth scopes to access Google services. Scopes are declared in appsscript.json at the project root:
{
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/script.external_request"
]
}Common scopes you may need:
| Scope | When to add it |
|---|---|
https://www.googleapis.com/auth/script.external_request |
Calling external APIs with UrlFetchApp |
https://www.googleapis.com/auth/script.scriptapp |
Creating or managing installable triggers |
https://www.googleapis.com/auth/script.send_mail |
Sending email on behalf of the user via MailApp |
https://www.googleapis.com/auth/spreadsheets |
Reading or writing Google Sheets data |
https://www.googleapis.com/auth/documents |
Reading or writing Google Docs data |
https://www.googleapis.com/auth/forms |
Reading or writing Google Forms data |
Handling granular OAuth
Google OAuth is granular — users are shown each requested scope individually and may choose to grant only some of them. Use ScriptApp.requireScopes() to validate that the user has granted the specific scopes a function needs, or ScriptApp.requireAllScopes() if a function depends on every scope declared in appsscript.json. Both methods end execution immediately and prompt the user for authorization if any required scope is missing.
// Use requireScopes() when a function only needs a subset of your declared scopes.
export const fetchAndLog = () => {
ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, [
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/spreadsheets",
]);
const response = UrlFetchApp.fetch("https://api.example.com/data");
const data = JSON.parse(response.getContentText());
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
sheet.getRange(sheet.getLastRow() + 1, 1).setValue(data.value);
};
// Use requireAllScopes() when a function relies on every scope in appsscript.json.
export const fullSync = () => {
ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL);
// ...
};See the Google Apps Script scopes documentation for the full guide on detecting missing scopes and triggering the authorization popup.
Common patterns
Type-safe server calls
Define functions in packages/server/src/index.ts:
export const getSheetData = async (
sheetName: string,
): Promise<{ headers: string[]; rows: string[][] }> => {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);
if (!sheet) throw new Error(`Sheet "${sheetName}" not found`);
const [headers, ...rows] = sheet.getDataRange().getValues();
return { headers, rows };
};Call them from any dialog with full type inference — no manual type declarations needed:
import { serverFunctions } from "@my-gas-app/shared/utils/server";
// TypeScript knows the return type: { headers: string[], rows: string[][] }
const { headers, rows } = await serverFunctions.getSheetData("Responses");
// Type error caught at compile time
console.log(rows.typo); // ✗ Property 'typo' does not existGAS globals (SpreadsheetApp, HtmlService, etc.) are scoped to packages/server only and won't leak into your client dialogs.
Adding a dialog
Generate a new dialog entrypoint:
npx create-gas-app add dialog settingsThis scaffolds the dialog files and automatically patches vite.config.ts to register the new entrypoint — no manual edits required.
Add an opener in packages/server/src/ui.ts:
export const openSettingsDialog = () => {
const html = HtmlService.createHtmlOutputFromFile("settings")
.setWidth(800)
.setHeight(600);
SpreadsheetApp.getUi().showModalDialog(html, "Settings");
};Export it from packages/server/src/index.ts:
export { onOpen, onInstall, openSidebar, openSettingsDialog } from "./ui";Now serverFunctions.openSettingsDialog() is available — typed — from any dialog.
Customising the Extensions menu
Standalone scripts do not have an Extensions menu — skip this section if you chose Standalone.
The generated onOpen in packages/server/src/ui.ts runs every time the file is opened and builds the add-on menu. The UI service differs per project type:
| Project type | UI service |
|---|---|
| Sheets | SpreadsheetApp.getUi() |
| Docs | DocumentApp.getUi() |
| Forms | FormApp.getUi() |
Add an item that opens a dialog:
export const onOpen = () => {
SpreadsheetApp.getUi()
.createAddonMenu()
.addItem("Open", "openSidebar")
.addItem("Settings", "openSettingsDialog") // ← add
.addToUi();
};Add an item that runs a server function directly:
export const onOpen = () => {
SpreadsheetApp.getUi()
.createAddonMenu()
.addItem("Open", "openSidebar")
.addSeparator()
.addItem("Import data", "importDataFromSheet")
.addToUi();
};Add a submenu:
export const onOpen = () => {
const ui = SpreadsheetApp.getUi();
ui.createAddonMenu()
.addItem("Open", "openSidebar")
.addSeparator()
.addSubMenu(
ui.createMenu("Tools")
.addItem("Import data", "importDataFromSheet")
.addItem("Export to CSV", "exportToCsv"),
)
.addToUi();
};Everything added to the menu must be exported from packages/server/src/index.ts so GAS can find it at the top level:
export { onOpen, onInstall, openSidebar, openSettingsDialog, importDataFromSheet } from "./ui";Tip: Menu items run as server-side functions — they can read/write data directly without going through
serverFunctions. Use them for one-shot operations. UseserverFunctionswhen you need to trigger an action from within a dialog.
Adding fonts
Each dialog's index.html already includes Google Fonts preconnect links. Add your font:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
/>Then use it in packages/shared/src/styles/global.css:
body { font-family: 'Inter', sans-serif; }If you're using Tailwind:
@theme inline {
--font-sans: 'Inter', sans-serif;
}For self-hosted fonts, drop the files in packages/shared/src/styles/fonts/ and use @font-face in global.css. Vite inlines them into the final HTML at build time via vite-plugin-singlefile.
Keeping bundles small
Each dialog builds as a single inlined HTML file. The scaffolded project already externalizes your framework and gas-client via an importmap — they load from esm.sh at runtime and are never bundled.
If you add a heavy library, externalize it the same way.
Step 1 — Add to the importmap in index.html:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.4",
"react-dom/": "https://esm.sh/react-dom@19.2.4/",
"gas-client": "https://esm.sh/gas-client@1.2.1",
"recharts": "https://esm.sh/recharts@2.15.3"
}
}
</script>Step 2 — Mark it as external in vite.config.ts:
rollupOptions: {
external: ["react", "react-dom", "react-dom/client", "gas-client", "recharts"],
output: { format: "es" },
}Tip: Check if the library is available on esm.sh before externalizing. Most npm packages work; native addons or Node-specific packages won't.
Addons
Tailwind CSS v4
CSS-first Tailwind with @tailwindcss/vite. No config file needed — just import in CSS and use classes. Global styles live in packages/shared/src/styles/global.css.
shadcn/ui
Generates components.json and a starter Button component using the unified radix-ui package. Add more components:
npx shadcn add card
npx shadcn add dialog
npx shadcn add data-tableOnly available with React.
Commitlint + Lefthook
Enforces Conventional Commits with @commitlint/config-conventional. Runs Prettier on staged files via lefthook before each commit.
ESLint
Generates eslint.config.js with ESLint 9 flat config, TypeScript support, and framework-specific rules:
| Framework | Extra plugins |
|---|---|
| React | eslint-plugin-react-hooks, eslint-plugin-react-refresh |
| Vue | eslint-plugin-vue |
| Svelte | eslint-plugin-svelte |
| SolidJS | eslint-plugin-solid |
| Vanilla | TypeScript rules only |
Adds lint and lint:fix scripts to package.json.
Adding addons to an existing project
Addons can be added after scaffolding with the add addon subcommand:
npx create-gas-app add addon tailwind
npx create-gas-app add addon eslint
npx create-gas-app add addon commitlint
npx create-gas-app add addon shadcnRun from the project root. The command auto-detects your framework and project name, writes the addon files, and updates package.json with the required dependencies. Then install:
npm installNote:
shadcnrequires React and Tailwind to be installed first.
Scripts reference
These scripts are available in every generated project. They are not part of the
create-gas-appCLI repo itself.
| Script | What it does |
|---|---|
dev |
deploy:dev + Vite dev server at https://localhost:$PORT |
build |
Production build → inlined HTML in dist/ |
build:dev |
Dev build (iframe wrappers) → dist/ |
deploy |
build + clasp:push |
deploy:dev |
build:dev + clasp:push |
setup:certs |
Generate local HTTPS certs with mkcert |
clasp:login |
Authenticate with Google |
clasp:create |
Create a new GAS project and write .clasp.json |
clasp:push |
Push dist/ to GAS |
clasp:open:script |
Open the Apps Script editor in your browser |
clasp:open:container |
Open the linked Sheets/Docs/Forms file |
format |
Format all files with Prettier |
type-check |
Run tsc --noEmit across the whole monorepo |
lint |
Run ESLint (only if ESLint addon was selected) |
lint:fix |
Run ESLint with auto-fix |
Override the dev port:
Edit the generated project's package.json scripts to set PORT in dev or build:dev
so your chosen port is always used.
Resources
- Google Apps Script — Guides — Concepts, tutorials, and how-to guides
- Google Apps Script — Reference — Full API reference for all GAS services
Acknowledgements
The Sheets add-on architecture is heavily inspired by enuchi/React-Google-Apps-Script — the original template that pioneered bundling React apps into GAS dialogs with a Webpack + Babel setup. Two of his packages are core dependencies of every generated project:
- gas-client — the promise-based wrapper around
google.script.runthat powers all type-safe server calls - gas-types-detailed — comprehensive TypeScript type definitions for the entire Google Apps Script API
Contributing
git clone https://github.com/vazhioli/create-gas-app
cd create-gas-app
bun install
bun run dev # watch mode — rebuilds on save
bun test-scaffold.ts # integration testsLicense
MIT