create-gas-app
v0.4.2
Published
The modern CLI to scaffold Google Apps Script apps with React, Vue, Svelte, or SolidJS
Downloads
655
Maintainers
Readme
create-gas-app
The modern CLI to build Google Apps Script add-ons with React, Vue, Svelte, or SolidJS.
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 3
○ Svelte 5
○ SolidJS
Select addons:
◼ Tailwind CSS v4
◼ shadcn/ui
◻ Commitlint + Lefthook
◻ ESLint
Which package manager?
● bun ○ pnpm ○ npm ○ yarn
Install dependencies now? Yes
Initialize a git repository? YesNote: 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/
├── apps/
│ └── my-gas-app/
│ ├── env.ts ← Runtime env — gitignored
│ └── dialogs/
│ ├── sidebar/
│ │ ├── index.html ← importmap + entry script (no bundled deps)
│ │ └── src/
│ │ ├── main.tsx
│ │ └── App.tsx
│ └── about/
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ └── App.tsx
├── 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
└── 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.
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.
// Here, fetchAndLog() only needs external request access — not triggers or mail.
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);
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);
ScriptApp.newTrigger("fullSync").timeBased().everyHours(1).create();
MailApp.sendEmail(Session.getActiveUser().getEmail(), "Sync complete", "Data updated.");
};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 settingsRegister it in vite.config.ts:
const entrypoints = [
{ name: "Sidebar", filename: "sidebar", appDir: "sidebar", template: "index.html" },
{ name: "Settings", filename: "settings", appDir: "settings", template: "index.html" }, // ← add
];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() // swap for DocumentApp / FormApp as needed
.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") // ← runs directly, no dialog
.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/[email protected]",
"react-dom/": "https://esm.sh/[email protected]/",
"gas-client": "https://esm.sh/[email protected]",
"recharts": "https://esm.sh/[email protected]"
}
}
</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 |
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 installFor tailwind, the command also prints the exact lines to add to vite.config.ts since that file may have been edited.
Note:
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 |
| lint | Run ESLint (only if ESLint addon was selected) |
| lint:fix | Run ESLint with auto-fix |
Override the dev port:
PORT=5173 npm run devResources
- 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
