Automatically import Xero bills into Google Sheets (Google Apps Script + OAuth2)

If you use Xero and Google Sheets on a daily basis, this automation tutorial is for you. In this post, you’ll learn how to automatically sync your Xero bills into a Google Sheet on a schedule, so your spending table stays up to date without copy-pasting.

We’ll build it in a production-friendly way: OAuth2 authorization (so Xero can securely grant access), safe credential storage in Apps Script, a sync function that writes into your sheet, and refresh tokens (via offline_access) so the automation keeps running long-term.

Create a Xero OAuth2 app

Create a Xero OAuth2 app in the Xero Developer portal and choose the web app / Standard auth code type (the standard flow Xero documents for third-party apps). The standard authorization code flow

The most important field is Redirect URI. Xero requires an exact, absolute redirect URI (no wildcards), so we will need the exact callback URL generated by your Apps Script project. Xero API – OAuth 2.0

Get a temporary redirect URI to create the app

In Apps Script, the OAuth2 library can generate the exact redirect URI for this project. Run the function below, copy the URL from the logs, and paste it into your Xero app settings to finish creating the app.

/**
 * Logs the Redirect URI you must paste into your Xero app settings.
 * This does NOT require Client ID/Secret.
 *
 * @returns {void}
 */
function logXeroRedirectUri() {
  const service = OAuth2.createService('Xero')
    .setCallbackFunction('xeroAuthCallback');
  Logger.log(service.getRedirectUri());
}

/**
 * OAuth callback endpoint (stub is fine for this step).
 * @param {Object} e
 * @returns {GoogleAppsScript.HTML.HtmlOutput}
 */
function xeroAuthCallback(e) {
  return HtmlService.createHtmlOutput('Callback ready.');
}

The Apps Script OAuth2 library is the standard way to handle OAuth consent and token refresh in Apps Script. (Link to the setup guide here).

Get and store Client ID / Client Secret in Script Properties

Once the app is created, copy the Client ID and Client Secret. You’ll need them in Apps Script to complete the OAuth flow.

Store only the Client ID and Client Secret in Script Properties. OAuth tokens are user-specific, so we store them in User Properties via the OAuth2 library’s property store.

const XERO_PROPS_ = {
  clientId: 'XERO_CLIENT_ID',
  clientSecret: 'XERO_CLIENT_SECRET',
  lastSyncIso: 'XERO_BILLS_LAST_SYNC_ISO',
};

/**
 * Saves Xero app credentials to Script Properties.
 * @param {string} clientId
 * @param {string} clientSecret
 * @returns {void}
 */
function saveXeroCredentials_(clientId, clientSecret) {
  PropertiesService.getScriptProperties()
    .setProperty(XERO_PROPS_.clientId, clientId)
    .setProperty(XERO_PROPS_.clientSecret, clientSecret);
}

/**
 * Reads Xero app credentials from Script Properties.
 * @returns {{clientId: string, clientSecret: string}}
 */
function getXeroCredentials_() {
  const props = PropertiesService.getScriptProperties();
  const clientId = props.getProperty(XERO_PROPS_.clientId) || '';
  const clientSecret = props.getProperty(XERO_PROPS_.clientSecret) || '';
  if (!clientId || !clientSecret) throw new Error('Missing Xero credentials in Script Properties.');
  return { clientId, clientSecret };
}

Replace the temporary callback with the real OAuth service + callback

Now that we have Client ID/Secret stored, we can build the real OAuth service and the real callback that stores tokens.

Important: tokens are user-specific, so we store them in User Properties via .setPropertyStore(PropertiesService.getUserProperties()).

Let’s replace the previous xeroAuthCallback() with this version:

/**
 * Creates and returns the OAuth2 service configured for Xero.
 * Client ID/Secret are in Script Properties; OAuth tokens are stored per-user in User Properties.
 *
 * @returns {OAuth2.Service}
 */
function getXeroService_() {
  const { clientId, clientSecret } = getXeroCredentials_();

  return OAuth2.createService('Xero')
    .setAuthorizationBaseUrl('https://login.xero.com/identity/connect/authorize')
    .setTokenUrl('https://identity.xero.com/connect/token')
    .setClientId(clientId)
    .setClientSecret(clientSecret)
    .setCallbackFunction('xeroAuthCallback')
    .setPropertyStore(PropertiesService.getUserProperties())
    .setCache(CacheService.getUserCache())
    .setLock(LockService.getUserLock())
    .setScope([
      'openid',
      'profile',
      'email',
      'offline_access',
      'accounting.transactions',
    ].join(' '))
    .setTokenHeaders({
      Authorization: 'Basic ' + Utilities.base64Encode(`${clientId}:${clientSecret}`),
    });
}

/**
 * OAuth callback endpoint called by Xero after consent.
 * This stores the access token + refresh token in the OAuth2 property store.
 *
 * @param {Object} e
 * @returns {GoogleAppsScript.HTML.HtmlOutput}
 */
function xeroAuthCallback(e) {
  const service = getXeroService_();
  const ok = service.handleCallback(e);
  return HtmlService.createHtmlOutput(ok ? 'Success! You can close this tab.' : 'Denied');
}

Authorize once

This is a one-time consent step: generate the authorization URL, open it in your browser, click Allow, then Xero redirects back to xeroAuthCallback() to finalize the connection.

You must request offline_access to receive a refresh token. Xero Developer – Offline access
Xero’s docs also note refresh tokens expire after a period (so if the integration stops running for too long, you may need to re-authorize).

/**
 * Returns the Xero consent screen URL. Open it and approve access.
 *
 * @returns {string}
 */
function getXeroAuthorizationUrl_() {
  const service = getXeroService_();
  return service.getAuthorizationUrl();
}

/**
 * Logs the authorization URL in Apps Script logs.
 *
 * @returns {void}
 */
function logXeroAuthorizationUrl_() {
  Logger.log(getXeroAuthorizationUrl_());
}

Run logXeroAuthorizationUrl_(), copy the logged URL, open it, and approve access. After that, your script can call Xero without prompting again.

Sync bills into Sheets (then add a trigger)

Xero is multi-tenant: first call the Connections endpoint to get a tenantId, then include xero-tenant-id in every API request. For incremental sync, use the If-Modified-Since header so each run only pulls bills modified since your last sync timestamp.

const XERO_API_BASE_ = 'https://api.xero.com/api.xro/2.0';
const XERO_BILLS_SHEET_ = 'Xero Bills';

/**
 * Resolves the active tenant ID from Xero connections.
 *
 * @param {OAuth2.Service} service
 * @returns {string}
 */
function getXeroTenantId_(service) {
  const res = UrlFetchApp.fetch('https://api.xero.com/connections', {
    method: 'get',
    headers: { Authorization: `Bearer ${service.getAccessToken()}` },
    muteHttpExceptions: true,
  });

  if (res.getResponseCode() !== 200) {
    throw new Error(`Failed to resolve tenant. ${res.getResponseCode()} — ${res.getContentText()}`);
  }

  const tenants = JSON.parse(res.getContentText());
  const tenantId = tenants?.[0]?.tenantId;
  if (!tenantId) throw new Error('No tenant connected to this token.');
  return tenantId;
}

/**
 * Performs a JSON request to Xero with common headers.
 *
 * @param {"get"|"post"|"put"|"delete"} method
 * @param {string} url
 * @param {Object|null} body
 * @param {{ ifModifiedSinceIso?: string }} [opts]
 * @returns {{code: number, text: string, json?: Object}}
 */
function xeroJsonRequest_(method, url, body, opts = {}) {
  const service = getXeroService_();
  if (!service.hasAccess()) throw new Error('Xero access not granted. Run the authorization flow first.');

  const tenantId = getXeroTenantId_(service);

  /** @type {GoogleAppsScript.URL_Fetch.URLFetchRequestOptions} */
  const options = {
    method,
    headers: {
      Authorization: `Bearer ${service.getAccessToken()}`,
      'xero-tenant-id': tenantId,
      Accept: 'application/json',
      ...(opts.ifModifiedSinceIso ? { 'If-Modified-Since': opts.ifModifiedSinceIso } : {}),
    },
    muteHttpExceptions: true,
  };

  if (body != null) {
    options.contentType = 'application/json';
    options.payload = JSON.stringify(body);
  }

  const res = UrlFetchApp.fetch(url, options);
  const code = res.getResponseCode();
  const text = res.getContentText();

  let json;
  try {
    json = text ? JSON.parse(text) : undefined;
  } catch (e) {}

  return { code, text, json };
}

/**
 * Returns ACCPAY invoices (bills) from Xero.
 *
 * @param {{ ifModifiedSinceIso?: string }} [params]
 * @returns {Array<Object>}
 */
function listBills_(params = {}) {
  const where = encodeURIComponent('Type=="ACCPAY"');
  const url = `${XERO_API_BASE_}/Invoices?where=${where}`;

  const { code, text, json } = xeroJsonRequest_('get', url, null, {
    ifModifiedSinceIso: params.ifModifiedSinceIso,
  });

  if (code !== 200) throw new Error(`Failed to list bills. ${code} — ${text}`);
  return /** @type {Array<Object>} */ (json?.Invoices || []);
}

/**
 * Upserts bills into a sheet using InvoiceID as the key.
 *
 * @param {SpreadsheetApp.Sheet} sheet
 * @param {Array<Object>} bills
 * @returns {void}
 */
function upsertBillsToSheet_(sheet, bills) {
  const headers = [
    'InvoiceID',
    'InvoiceNumber',
    'ContactName',
    'Status',
    'Date',
    'DueDate',
    'CurrencyCode',
    'Total',
    'AmountDue',
    'UpdatedDateUTC',
  ];

  const current = sheet.getDataRange().getValues();
  if (!current.length || current[0].join() !== headers.join()) {
    sheet.clear();
    sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
  }

  const data = sheet.getDataRange().getValues();
  /** @type {Map<string, number>} */
  const rowById = new Map();

  for (let r = 2; r <= data.length; r++) {
    const id = String(data[r - 1][0] || '');
    if (id) rowById.set(id, r);
  }

  const rowsToAppend = [];

  bills.forEach(b => {
    const row = [
      b.InvoiceID || '',
      b.InvoiceNumber || '',
      b.Contact?.Name || '',
      b.Status || '',
      b.Date || '',
      b.DueDate || '',
      b.CurrencyCode || '',
      b.Total ?? '',
      b.AmountDue ?? '',
      b.UpdatedDateUTC || '',
    ];

    const invoiceId = String(b.InvoiceID || '');
    const existingRow = rowById.get(invoiceId);

    if (existingRow) {
      sheet.getRange(existingRow, 1, 1, headers.length).setValues([row]);
    } else {
      rowsToAppend.push(row);
    }
  });

  if (rowsToAppend.length) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rowsToAppend.length, headers.length).setValues(rowsToAppend);
  }
}

/**
 * Main sync: pulls bills changed since last sync and writes them into Google Sheets.
 *
 * @returns {void}
 */
function syncXeroBillsToSheet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(XERO_BILLS_SHEET_) || ss.insertSheet(XERO_BILLS_SHEET_);

  const props = PropertiesService.getScriptProperties();
  const lastSyncIso = props.getProperty(XERO_PROPS_.lastSyncIso) || '';

  const bills = listBills_({ ifModifiedSinceIso: lastSyncIso || undefined });
  upsertBillsToSheet_(sheet, bills);

  props.setProperty(XERO_PROPS_.lastSyncIso, new Date().toISOString());
}

/**
 * Creates an hourly time-driven trigger for syncXeroBillsToSheet().
 *
 * @returns {void}
 */
function installXeroBillsTrigger_() {
  ScriptApp.newTrigger('syncXeroBillsToSheet')
    .timeBased()
    .everyHours(1)
    .create();
}

A clean first run is: save credentials → run logXeroAuthorizationUrl_() and approve → run syncXeroBillsToSheet() once → run installXeroBillsTrigger_() to automate the sync.


You can now install a simple time-driven trigger on syncXeroBillsToSheet() to keep your spreadsheet up to date automatically. From here, it’s easy to adapt the same structure to pull other Xero data: switch from bills (ACCPAY) to sales invoices (ACCREC), filter only paid documents, or add extra columns depending on what you want to analyse.

Build Your Google Sheets Like React Components (with Apps Script)

Faster dashboards, cleaner code, and a rendering engine powered by the Sheets API.

GitHub Repo MIT License Apps Script Compatible Sheets API Required

Google Sheets is fantastic for quick dashboards, tracking tools, and prototypes. And with Apps Script, you can make them dynamic, automated, and even smarter.

But the moment you start building something real, a complex layout, a styled dashboard, a multi-section report, the Apps Script code quickly turns into:

  • messy
  • repetitive
  • slow
  • painful to maintain

Setting backgrounds, borders, number formats, writing values… everything requires individual calls, and your script balloon quickly.

To make this easier, you usually have two options:

  1. Inject data into an existing template
  2. Or…using RenderSheet

RenderSheet is a tiny, component-based renderer for Google Sheets, inspired by React’s architecture and built entirely in Apps Script.
Let’s explore how it works.!

Introducing RenderSheet

RenderSheet is a lightweight, React-inspired renderer for Google Sheets.

Instead of scattering .setValue(), .setBackground(), .setFontWeight(), etc. everywhere in your script, you write components:

  • <Title />
  • <HeaderRow />
  • <DataTable />
  • <SummaryRow />
  • <StatusPill />, etc.

Each component knows how to render itself.
Your spreadsheet becomes a clean composition of reusable blocks, just like a frontend UI.

Why a “React-like” approach?

React works because it forces you to think in components:

  • a Header component
  • a Table component
  • a Summary component
  • a StatusPill component

Each component knows how to render itself, and the app becomes a composition of smaller reusable pieces.I wanted the exact same thing, but for spreadsheets.

With RenderSheet, instead of writing:

sheet.getRange("A1").setValue("Hello");
sheet.getRange("A1").setBackground("#000");
sheet.getRange("A1").setFontWeight("bold");
// and so on...

You write:

class HeaderComponent extends SheetComponent {
  render() {
    this.context.writeRange("Sheet1!A1", [["Hello"]]);
    this.context.setBackground("Sheet1", "A1", "#000");
  }
}

No direct API calls. No multiple setValue() calls.
Just a clean render() method.

The Core Idea: One Render = Two Sheets API Calls

To achieve this “single render” effect, we simply divide to conquer:

  • one API call for injecting the data
  • and another for applying all the styling

Before we can make the magic happen, we need to enable the Advanced Google Sheets API in our Apps Script project.
This unlocks the batchUpdate method, the real engine behind RenderSheet.

To do so, just click the little + button on the left side panel, in front of Services, and select Google Sheets API:

This gives you access to the batchUpdate method, the real engine behind RenderSheet.

Now let’s look at the core mechanism.

The SheetContext (the Render Engine)

SheetContext is the core of RenderSheet.
It collects all writes and formatting instructions and applies them in two API calls, using easy functions such as:

  • all data writes (writeRange)
  • all formatting instructions (setBackground, setTextFormat, setAlignment…)

But here’s the key:

None of these functions execute anything immediately.
They only describe what the component wants to render.

The actual rendering only happens at the end, through two API calls.

You can think of SheetContext as your “toolbox”, and feel free to extend it with your own helpers.

Here is its full implementation:

class SheetContext {
  constructor(spreadsheetId) {
    this.spreadsheetId = spreadsheetId;
    this.valueRanges = [];
    this.requests = [];
  }

  writeRange(rangeA1, values) {
    this.valueRanges.push({ range: rangeA1, values });
  }

  setBackground(sheetName, a1Range, color) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    this.requests.push({
      repeatCell: {
        range: gridRange,
        cell: { userEnteredFormat: { backgroundColor: this.hexToRgbColor(color) }},
        fields: "userEnteredFormat.backgroundColor",
      },
    });
  }

  setNumberFormat(sheetName, a1Range, numberFormatPattern) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    this.requests.push({
      repeatCell: {
        range: gridRange,
        cell: {
          userEnteredFormat: {
            numberFormat: { type: "NUMBER", pattern: numberFormatPattern },
          },
        },
        fields: "userEnteredFormat.numberFormat",
      },
    });
  }

  setTextFormat(sheetName, a1Range, options) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    const textFormat = {};
    if (options.bold !== undefined) textFormat.bold = options.bold;
    if (options.italic !== undefined) textFormat.italic = options.italic;
    if (options.underline !== undefined) textFormat.underline = options.underline;
    if (options.fontSize !== undefined) textFormat.fontSize = options.fontSize;
    if (options.color) {
      textFormat.foregroundColor = this.hexToRgbColor(options.color);
    }

    this.requests.push({
      repeatCell: {
        range: gridRange,
        cell: { userEnteredFormat: { textFormat }},
        fields: "userEnteredFormat.textFormat",
      },
    });
  }

  setAlignment(sheetName, a1Range, options) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    const format = {};
    const fields = [];

    if (options.horizontal) {
      format.horizontalAlignment = options.horizontal;
      fields.push("userEnteredFormat.horizontalAlignment");
    }
    if (options.vertical) {
      format.verticalAlignment = options.vertical;
      fields.push("userEnteredFormat.verticalAlignment");
    }

    this.requests.push({
      repeatCell: {
        range: gridRange,
        cell: { userEnteredFormat: format },
        fields: fields.join(","),
      },
    });
  }

  setWrapStrategy(sheetName, a1Range, wrapStrategy) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    this.requests.push({
      repeatCell: {
        range: gridRange,
        cell: { userEnteredFormat: { wrapStrategy }},
        fields: "userEnteredFormat.wrapStrategy",
      },
    });
  }

  setBorders(sheetName, a1Range, options) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    const border = {
      style: options.style || "SOLID",
      width: options.width || 1,
      color: options.color ? this.hexToRgbColor(options.color) : undefined,
    };

    this.requests.push({
      updateBorders: {
        range: gridRange,
        top: border,
        bottom: border,
        left: border,
        right: border,
        innerHorizontal: border,
        innerVertical: border,
      },
    });
  }

  mergeRange(sheetName, a1Range, mergeType) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    this.requests.push({
      mergeCells: {
        range: gridRange,
        mergeType: mergeType || "MERGE_ALL",
      },
    });
  }

  unmergeRange(sheetName, a1Range) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    this.requests.push({ unmergeCells: { range: gridRange }});
  }

  autoResizeColumns(sheetName, a1Range) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange) return;

    this.requests.push({
      autoResizeDimensions: {
        dimensions: {
          sheetId: gridRange.sheetId,
          dimension: "COLUMNS",
          startIndex: gridRange.startColumnIndex,
          endIndex: gridRange.endColumnIndex,
        },
      },
    });
  }

  setDataValidationList(sheetName, a1Range, values) {
    const gridRange = this.convertA1ToGridRange(sheetName, a1Range);
    if (!gridRange || values.length === 0) return;

    this.requests.push({
      setDataValidation: {
        range: gridRange,
        rule: {
          condition: {
            type: "ONE_OF_LIST",
            values: values.map(v => ({ userEnteredValue: v })),
          },
          showCustomUi: true,
        },
      },
    });
  }

  addRequest(request) {
    this.requests.push(request);
  }

  commit() {
    if (this.requests.length > 0) {
      Sheets.Spreadsheets.batchUpdate(
        { requests: this.requests },
        this.spreadsheetId
      );
    }

    if (this.valueRanges.length > 0) {
      Sheets.Spreadsheets.Values.batchUpdate(
        { valueInputOption: "USER_ENTERED", data: this.valueRanges },
        this.spreadsheetId
      );
    }
  }

  hexToRgbColor(hex) {
    let clean = hex.replace("#", "");
    if (clean.length === 3) {
      clean = clean.split("").map(ch => ch + ch).join("");
    }
    return {
      red: parseInt(clean.substring(0, 2), 16) / 255,
      green: parseInt(clean.substring(2, 4), 16) / 255,
      blue: parseInt(clean.substring(4, 6), 16) / 255,
    };
  }

  convertA1ToGridRange(sheetName, a1Range) {
    const ss = SpreadsheetApp.openById(this.spreadsheetId);
    const sheet = ss.getSheetByName(sheetName);
    if (!sheet) throw new Error(`Sheet not found: ${sheetName}`);

    const range = sheet.getRange(a1Range);
    return {
      sheetId: sheet.getSheetId(),
      startRowIndex: range.getRow() - 1,
      endRowIndex: range.getRow() - 1 + range.getNumRows(),
      startColumnIndex: range.getColumn() - 1,
      endColumnIndex: range.getColumn() - 1 + range.getNumColumns(),
    };
  }
}

The SheetComponent (your building block)

If SheetContext is the engine, SheetComponent is the UI framework you build with.

Components never call the Sheets API themselves.
They only describe what they want through this.context.

class SheetComponent {
  constructor(context, props) {
    this.context = context;
    this.props = props || {};
  }

  render() {
    // To be overridden
  }

  renderChild(ComponentClass, props) {
    const child = new ComponentClass(this.context, props);
    child.render();
    return child;
  }
}

This gives you:

  • clean reusable components
  • predictable rendering
  • composition (just like React)

Example:

class StatusPill extends SheetComponent {
  render() {
    const { sheetName, range, status } = this.props;

    const color =
      status === "Done" ? "#10b981" :
      status === "Blocked" ? "#ef4444" :
      "#facc15";

    this.context.setBackground(sheetName, range, color);
    this.context.setTextFormat(sheetName, range, { bold: true, color: "#fff" });
    this.context.setAlignment(sheetName, range, { horizontal: "CENTER" });
  }
}

An easy example of use

Let’s build a tiny dashboard using:

  • TitleComponent
  • HeaderRowComponent
  • DataTableComponent
  • SummaryRowComponent

Each is a simple SheetComponent that writes values and applies styles using this.context.

**
 * Component that writes a big title and optional subtitle at the top.
 */
class TitleComponent extends SheetComponent {

  render() {
    const sheetName = (this.props.sheetName);
    const title = (this.props.title);
    const subtitle = (this.props.subtitle);

    this.context.writeRange(`${sheetName}!A1`, [[title]]);
    this.context.mergeRange(sheetName, "A1:D1", "MERGE_ALL");
    this.context.setBackground(sheetName, "A1:D1", "#0f172a");
    this.context.setTextFormat(sheetName, "A1:D1", {
      bold: true,
      fontSize: 16,
      color: "#ffffff",
    });
    this.context.setAlignment(sheetName, "A1:D1", { horizontal: "CENTER", vertical: "MIDDLE" });

    if (subtitle) {
      this.context.writeRange(`${sheetName}!A2`, [[subtitle]]);
      this.context.mergeRange(sheetName, "A2:D2", "MERGE_ALL");
      this.context.setTextFormat(sheetName, "A2:D2", {
        italic: true,
        color: "#475569",
      });
      this.context.setAlignment(sheetName, "A2:D2", { horizontal: "CENTER", vertical: "MIDDLE" });
    }
  }
}

/**
 * Component that writes a header row with styling.
 */
class HeaderRowComponent extends SheetComponent {
  /**
   * @returns {void}
   */
  render() {
    const sheetName = (this.props.sheetName);
    const headers = (this.props.headers);
    const rowIndex = (this.props.rowIndex);

    const lastColLetter = columnIndexToLetter(headers.length);
    const rangeA1 = `${sheetName}!A${rowIndex}:${lastColLetter}${rowIndex}`;

    this.context.writeRange(rangeA1, [headers]);
    this.context.setBackground(sheetName, `A${rowIndex}:${lastColLetter}${rowIndex}`, "#1e293b");
    this.context.setTextFormat(sheetName, `A${rowIndex}:${lastColLetter}${rowIndex}`, {
      bold: true,
      color: "#ffffff",
    });
    this.context.setAlignment(sheetName, `A${rowIndex}:${lastColLetter}${rowIndex}`, {
      horizontal: "CENTER",
      vertical: "MIDDLE",
    });
  }
}

/**
 * Component that writes a 2D data table starting at a given row.
 */
class DataTableComponent extends SheetComponent {
  /**
   * @returns {void}
   */
  render() {
    const sheetName = (this.props.sheetName);
    const rows = (this.props.rows);
    const startRow = (this.props.startRow);

    if (!rows || rows.length === 0) return;

    const numCols = rows[0].length;
    const lastColLetter = columnIndexToLetter(numCols);
    const endRow = startRow + rows.length - 1;
    const rangeA1 = `${sheetName}!A${startRow}:${lastColLetter}${endRow}`;

    this.context.writeRange(rangeA1, rows);
    this.context.setBorders(sheetName, `A${startRow}:${lastColLetter}${endRow}`, {
      style: "SOLID",
      width: 1,
      color: "#cbd5e1",
    });
    this.context.setAlignment(sheetName, `A${startRow}:${lastColLetter}${endRow}`, {
      vertical: "MIDDLE",
    });
    this.context.setWrapStrategy(sheetName, `A${startRow}:${lastColLetter}${endRow}`, "WRAP");
  }
}

class SummaryRowComponent extends SheetComponent {
  /**
   * @returns {void}
   */
  render() {
    const sheetName = (this.props.sheetName);
    const label = (this.props.label);
    const rowIndex = (this.props.rowIndex);
    const numCols = (this.props.numCols);
    const amountColumnIndex = (this.props.amountColumnIndex);

    const lastColLetter = columnIndexToLetter(numCols);
    const labelCell = `A${rowIndex}`;
    const amountCell = `${columnIndexToLetter(numCols)}${rowIndex}`;
    const labelRangeSingle = `${sheetName}!${labelCell}`;
    const fullRowRange = `A${rowIndex}:${lastColLetter}${rowIndex}`;
    const amountRange = `${sheetName}!${amountCell}`;

    // Write label in a single cell only
    this.context.writeRange(labelRangeSingle, [[label]]);

    // Then merge the label area visually
    this.context.mergeRange(
      sheetName,
      `A${rowIndex}:${columnIndexToLetter(numCols - 1)}${rowIndex}`,
      "MERGE_ALL"
    );

    this.context.setAlignment(sheetName, fullRowRange, {
      horizontal: "RIGHT",
      vertical: "MIDDLE",
    });
    this.context.setBackground(sheetName, fullRowRange, "#e2e8f0");
    this.context.setTextFormat(sheetName, fullRowRange, {
      bold: true,
    });

    const dataStartRow = (this.props.dataStartRow);
    const dataEndRow = (this.props.dataEndRow);
    const amountColLetter = columnIndexToLetter(amountColumnIndex);
    const sumFormula = `=SUM(${amountColLetter}${dataStartRow}:${amountColLetter}${dataEndRow})`;

    this.context.writeRange(amountRange, [[sumFormula]]);
    this.context.setNumberFormat(sheetName, amountCell, "#,##0.00");
  }
}

Now let’s assemble everything inside a root component:

/**
 * Root dashboard component that composes title, headers, data and summary.
 */
class DemoDashboardComponent extends SheetComponent {

  render() {
    const sheetName = (this.props.sheetName);
    const headers = (this.props.headers);
    const rows = (this.props.rows);

    this.renderChild(TitleComponent, {
      sheetName,
      title: "Mini Apps Script Component Demo",
      subtitle: "Rendered with a single batchUpdate + values.batchUpdate",
    });

    const headerRowIndex = 4;
    this.renderChild(HeaderRowComponent, {
      sheetName,
      headers,
      rowIndex: headerRowIndex,
    });

    const dataStartRow = headerRowIndex + 1;
    this.renderChild(DataTableComponent, {
      sheetName,
      rows,
      startRow: dataStartRow,
    });

    const dataEndRow = dataStartRow + rows.length - 1;
    const summaryRowIndex = dataEndRow + 2;

    this.renderChild(SummaryRowComponent, {
      sheetName,
      label: "Total amount",
      rowIndex: summaryRowIndex,
      numCols: headers.length,
      amountColumnIndex: headers.length,
      dataStartRow,
      dataEndRow,
    });

    const lastColLetter = columnIndexToLetter(headers.length);
    this.context.autoResizeColumns(sheetName, `A1:${lastColLetter}${summaryRowIndex}`);
  }
}

Here’s a screenshot of the spreadsheet output, where you can see how each component contributes to the final layout:

Rendering the dashboard:

/**
 * Entry point: renders a full demo dashboard using the component system.
 *
 * @returns {void}
 */
function renderDemoDashboard() {
  const ss = SpreadsheetApp.openById("1mRno9TPj1iXGQ_lEqBIy3jOz4tyJm6upsVhklGuG1BQ")
  const spreadsheetId = ss.getId();
  const sheetName = "Demo";
  const headers = ["Project", "Owner", "Hours", "Amount"];
  const rows = [
    ["Surf Schedule App", "Lidia", 6, 750],
    ["Invoice Generator", "Lidia", 4, 520],
    ["ColorMyPie Add-on", "Lidia", 3, 380],
    ["Client Dashboard", "Lidia", 5, 640],
  ];

  renderSheet(spreadsheetId, DemoDashboardComponent, {
    sheetName,
    headers,
    rows,
  });
}

Even though the initial setup is a bit more structured, the final result:

  • renders in 1 second
  • stays perfectly maintainable
  • allows components to be reused in any dashboard

Exactly the power of React, but in Google Sheets.

Final Thoughts

RenderSheet brings structure, speed, and clarity to Google Sheets development.
If you’re tired of repetitive formatting code or want to build Sheets dashboards the same way you build UIs, this pattern completely changes the experience.

Let me know what you think, and feel free to contribute!

GitHub Repo

Unit testing in Google Apps Script: QUnitGS2 and a simple console runner

When your Apps Script project is just one tiny function attached to a Sheet, you can get away with “click Run and hope for the best”.

But as soon as your codebase grows, multiple files, complex business rules, integrations with Sheets, Docs, external APIs, that approach stops scaling. A small change in one function can silently break something completely different.

That’s where unit testing in Google Apps Script comes in.

In this article, we’ll cover:

  1. What unit testing is and why it matters for Apps Script
  2. The current landscape of unit testing solutions for GAS (QUnitGS2, GasT, Utgs, Jest + clasp, etc.)
  3. Classic unit testing principles adapted to Apps Script (AAA, dependency injection)
  4. How to use QUnitGS2 / QUnit2GS to build a proper test suite in Apps Script
  5. A lightweight console-based test runner that runs all test_ functions and logs PASS/FAIL in the execution log

By the end, you’ll have both a full-featured QUnit setup and a “zero-UI” runner you can use as a quick safety net in any project.

What is unit testing and why is it important?

Unit testing is the practice of testing small, isolated pieces of code (units), usually at the function level.

A typical unit test:

  • Calls a function with some inputs
  • Compares the result to an expected value
  • Fails loudly if they don’t match

In JavaScript (and therefore Apps Script), a unit test with QUnit looks like this:

QUnit.test('add() adds two numbers', (assert) => {
  const result = add(1, 2);
  assert.equal(result, 3, '1 + 2 should equal 3');
});

Why unit testing is especially useful in Google Apps Script projects:

  • Catch regressions early. When you refactor a Sheets automation or a Docs generator, tests tell you if something broke immediately instead of your users or clients discovering it later.
  • Refactor with confidence. You can rename functions, split files, change logic – and rely on tests as a safety net.
  • Executable documentation. A test like test_shouldApplyDiscountForTotalsOver1000 is more explicit than a vague comment buried in the code.
  • Faster debugging loops. Instead of manually re-running forms or simulating UI flows, you run the test suite and see exactly which scenario fails.

In “normal” JavaScript environments (Node/React), developers use tools like Jest, Mocha, QUnit, Ava… but Apps Script runs in a special environment that introduces a few constraints.

Limitations of Apps Script and existing unit testing solutions

Building a reliable unit testing structure in Google Apps Script (GAS) is essential for maintaining complex projects. But Apps Script is not a standard Node.js runtime:

  • No native local file system
  • No npm test directly inside the IDE
  • Limited setTimeout / async patterns
  • Heavy reliance on global services like SpreadsheetApp, DriveApp, MailApp, etc.

Because of that, unit testing solutions tend to fall into two big families:

  1. In-GAS frameworks – tests run on Google’s servers inside the Apps Script runtime
  2. Local testing – you pull code down with clasp and test it with Node tools, mocking Google services

In-GAS frameworks

These live directly in your Apps Script project.

ApproachFramework / ToolHow it worksProsCons
QUnit web UIQUnitGS2 / QUnit2GS
Adaptation of the popular QUnit JS library. Tests run in GAS, results shown via a doGet() web app.
Familiar QUnit API, nice HTML report, well documented, works well with Apps Script web apps.Requires deploying as a web app to see results; one more moving piece in your workflow.
TAP-style consoleGasT
TAP-compatible testing framework; tests log TAP output to the execution log.
Simple setup, console-based output, easy to integrate into basic scripts.Smaller community, less documentation than QUnit.
Minimal console libsUtgs / bmUnitTest
Lightweight libs focused on simple assertions and PASS/FAIL logs in Logger.log.
Very easy to drop into any script, no web deployment required.No GUI, you manage structure and conventions yourself.

Local testing with clasp + Jest/Ava

With the clasp CLI you can clone your Apps Script project locally, and then use any Node test framework you like (Jest, Ava, etc.) with mocks for Google services.

  • How it works
    • Use clasp to pull your code locally.
    • Write Jest/Ava tests.
    • Mock things like SpreadsheetApp and UrlFetchApp.
    • Run npm test locally.
  • Pros
    • Full Node ecosystem (TypeScript, Jest watch mode, coverage reports, CI/CD, VS Code integration).
  • Cons
    • Higher setup cost, especially for mocking Google services accurately.
    • Best suited to modular projects where business logic is already decoupled from Apps Script services.

Quick conclusion

If you want:

  • A visual, browser-based test runner → QUnitGS2 / QUnit2GS is an excellent choice
  • A blazing-fast, Node-like experience for pure logic → clasp + Jest is great.
  • A quick in-editor check that runs functions and logs results → a small custom console runner (or Utgs/bmUnitTest) is the most direct option.

In the rest of this article, we’ll combine both worlds:

  • QUnitGS2 for a “real” test suite with a UI
  • A tiny log-based test runner you can drop into any project

Classic unit testing principles in the Apps Script world

Before we jump into QUnit and custom runners, it’s worth aligning on some basic testing principles and how they apply to Apps Script.

AAA: Arrange, Act, Assert

A readable test follows the AAA pattern:

  1. Arrange – set up inputs, initial state, mocks
  2. Act – call the function under test
  3. Assert – verify the result (return value or side effect)

Example (pseudo-code):

function test_addTwoNumbers() {
  // Arrange
  const a = 2;
  const b = 3;
  const expected = 5;

  // Act
  const actual = add(a, b);

  // Assert
  assert(actual, expected, 'add() should return 5 for (2, 3)');
}

Labeling those three steps makes your Apps Script tests much easier to read and maintain.

Isolation and dependency injection in Apps Script

True unit tests should focus on one unit of logic at a time. That’s tricky in Apps Script because your code often calls global services directly:

function myFunction() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet(); // tightly coupled
  // ...
}

To make this testable, you can use dependency injection:

const fakeSpreadsheetService = {
  getActiveSpreadsheet() {
    return { /* fake sheet object */ };
  },
};

// In your test:
myFunction(fakeSpreadsheetService);

This pattern works great with both QUnitGS2 and a simple console runner.

Using QUnitGS2 (QUnit2GS) in Google Apps Script

Let’s start with a more “formal” test suite using QUnitGS2, which adapts the popular QUnit framework to Apps Script.

4.1. Install QUnitGS2 in your project

  1. Open your Apps Script project.
  2. Click add a library

3. Add the following script ID, and click Add:

1tXPhZmIyYiA_EMpTRJw0QpVGT5Pdb02PpOHCi9A9FFidblOc9CY_VLgG

A function to test

We’ll define a simple pure function to keep the focus on testing:

/**
 * Calculates the total amount after applying a percentage discount.
 *
 * @param {number} amount - Base amount before discount.
 * @param {number} discountPercent - Discount percentage between 0 and 100.
 * @return {number} The discounted amount, rounded to 2 decimals.
 * @throws {Error} If amount or discountPercent are invalid.
 */
function calculateDiscountedTotal(amount, discountPercent) {
  if (typeof amount !== 'number' || Number.isNaN(amount) || amount < 0) {
    throw new Error('amount must be a non-negative number');
  }

  if (
    typeof discountPercent !== 'number' ||
    Number.isNaN(discountPercent) ||
    discountPercent < 0 ||
    discountPercent > 100
  ) {
    throw new Error('discountPercent must be between 0 and 100');
  }

  const discount = (amount * discountPercent) / 100;
  const total = amount - discount;

  return Number(total.toFixed(2));
}

Wire up the QUnit web app

Add this in (for example) QUnitRunner.gs:

/* global QUnitGS2 */

/**
 * QUnit instance provided by QUnitGS2.
 * @type {QUnit}
 */
const QUnit = QUnitGS2.QUnit;

/**
 * Web app entry point that runs the QUnit test suite and returns HTML results.
 *
 * @param {Object} e - Web app request event.
 * @return {Object} HTML output with QUnit UI.
 */
function doGet(e) {
  QUnitGS2.init();

  registerTests();

  QUnit.start();
  return QUnitGS2.getHtml();
}

/**
 * Called from the client-side HTML to retrieve QUnit results
 * after the server-side test run completes.
 *
 * @return {Object} JSON results from the QUnitGS2 server.
 */
function getResultsFromServer() {
  return QUnitGS2.getResultsFromServer();
}

Define QUnit tests

Now create registerTests() and write your tests:

/**
 * Registers all QUnit test modules.
 */
function registerTests() {
  QUnit.module('calculateDiscountedTotal', () => {
    QUnit.test('returns original amount when discount is 0%', (assert) => {
      const result = calculateDiscountedTotal(100, 0);
      assert.equal(result, 100, '100 with 0% discount should stay 100');
    });

    QUnit.test('applies percentage discount correctly', (assert) => {
      const result = calculateDiscountedTotal(200, 10);
      assert.equal(result, 180, '10% discount on 200 should be 180');
    });

    QUnit.test('rounds to 2 decimals', (assert) => {
      const result = calculateDiscountedTotal(99.99, 5);
      assert.equal(result, 94.99, 'Should round result to 2 decimals');
    });

    QUnit.test('throws on negative amount', (assert) => {
      assert.throws(
        () => calculateDiscountedTotal(-10, 10),
        /amount must be a non-negative number/,
        'Negative amounts should throw'
      );
    });

    QUnit.test('throws when discount > 100%', (assert) => {
      assert.throws(
        () => calculateDiscountedTotal(100, 150),
        /discountPercent must be between 0 and 100/,
        'Discount > 100% should throw'
      );
    });
  });
}

Deploy as a web app and run tests

  1. Deploy → New deployment → Web app
  2. Execute as: your account
  3. Who has access: as you prefer (e.g. “Only me”)
  4. Copy the web app URL and open it in your browser

QUnitGS2 will:

  • Initialize QUnit
  • Run your registerTests()
  • Show a full QUnit HTML report with passing/failing tests

A simple console-based unit test runner (no web app required)

Sometimes you just want a quick pass/fail check in the Execution log without deploying a web app or integrating a full framework.

The script below implements a self-contained test runner that:

  • Looks for all global functions whose names start with test_
  • Runs them one by one
  • Logs PASS / FAIL and a short summary in the Apps Script execution log
// --- TEST UTILITIES: Place this in a file named 'TestRunner.gs' ---

/** @type {string} */
const TEST_PREFIX = 'test_';

/**
 * Checks if two values are strictly equal and logs a PASS/FAIL message.
 *
 * @param {unknown} actual - The value returned by the function under test (Act).
 * @param {unknown} expected - The predetermined correct value (Arrange).
 * @param {string} message - Description of the assertion.
 * @return {boolean} True if the assertion passes, false otherwise.
 */
function assert(actual, expected, message) {
  if (actual === expected) {
    Logger.log(`✅ PASS: ${message}`);
    return true;
  }

  Logger.log(`❌ FAIL: ${message} - Expected: ${expected}, Actual: ${actual}`);
  return false;
}

/**
 * Iterates through all global functions and runs those prefixed with 'test_'.
 * This acts as the central test runner.
 *
 * Run this manually from the Apps Script editor to execute the whole suite.
 */
function runAllTests() {
  Logger.log('====================================');
  Logger.log('🚀 STARTING UNIT TEST SUITE');
  Logger.log('====================================');

  let totalTests = 0;
  let passedTests = 0;
  let failedTests = 0;

  const globalScope = typeof globalThis !== 'undefined' ? globalThis : this;

  // Scan global functions and execute those starting with TEST_PREFIX.
  for (const key in globalScope) {
    if (Object.prototype.hasOwnProperty.call(globalScope, key)) {
      const candidate = globalScope[key];
      if (typeof candidate === 'function' && key.startsWith(TEST_PREFIX)) {
        const testName = key;
        Logger.log(`\n--- Running: ${testName} ---`);

        try {
          candidate();
          passedTests++;
        } catch (e) {
          failedTests++;
          const message = e instanceof Error ? e.message : String(e);
          Logger.log(`💥 ERROR in ${testName}: ${message}`);
        }

        totalTests++;
      }
    }
  }

  Logger.log('\n====================================');
  Logger.log(`RESULTS: ${totalTests} tests run`);
  Logger.log(`PASSED: ${passedTests}`);
  Logger.log(`FAILED: ${failedTests}`);
  Logger.log('====================================');
}
  1. Paste TestRunner.gs into your Apps Script project.
  2. Add tests by creating functions whose names start with test_ (in the same project).
  3. In the editor, select runAllTests and click Run ▶️.
  4. Open View → Logs to see a step-by-step PASS/FAIL log and the final summary.

This gives you:

  • A very fast feedback loop while coding
  • No web app deployment required
  • A simple way to run a quick suite every time you touch the script

If you like this pattern, you can evolve it:

  • Add more assertion helpers (assertNotEquals, assertTrue, assertDeepEqual, …)
  • Add timing information per test
  • Exit early if a critical test fails (for long suites)

Putting it together

There’s no single “right” way to do unit testing in Google Apps Script, but a good strategy looks like this:

  • Use pure functions + dependency injection for your core business logic
  • Cover that logic with tests – either with QUnitGS2 (web UI) or your own console runner
  • Keep Apps Script service calls (SpreadsheetApp, DriveApp, etc.) thin and easy to mock
  • For bigger projects or front-end code, consider clasp + Jest and a local toolchain

Start small: pick one critical module (invoice calculation, validation rules, date logic…), write a handful of tests, and get used to running them every time you make a change.

Once you’ve seen a refactor go through while your QUnitGS2 panel and/or console runner stays fully green, you’ll never want to go back to “click Run and cross your fingers” again.

From Google Docs Placeholders to Spreadsheet Automation

In othe previous article, we learned how to replace placeholders like [name] or [date] inside a Google Doc using Apps Script. We also made sure to keep the original file intact by creating a copy before applying replacements.

That’s great for a single document… but what if you need to create dozens of personalized files? Contracts, certificates, letters, all with different values?

The answer: connect your Google Doc template to a Google Sheet.
Each row of your sheet will represent a new document, and the script will:

  1. Create a copy of the template
  2. Replace all placeholders with values from the sheet
  3. Paste the link of the generated file back into the spreadsheet

Step 1 – Prepare your Spreadsheet

Create a sheet with the following structure (headers in the first row):

Notice the last column generatedLink, that’s where the script will paste the URL of each generated document.

Step 2 – Prepare your Google Doc Template

In your template, write placeholders between brackets:

Step 3 – The Script

This time the script will be bounded to our Google Sheets.

Open the Apps Script editor from your Spreadsheet (Extensions → Apps Script) and paste:

/**
 * Generate documents from a template Doc,
 * replacing placeholders with data from the sheet,
 * and storing links back in the sheet.
 */
function generateDocsFromSheet() {
  // IDs
  const TEMPLATE_ID = 'YOUR_TEMPLATE_DOC_ID_HERE';
  const FOLDER_ID = 'YOUR_TARGET_FOLDER_ID_HERE';

  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getActiveSheet();
  const data = sheet.getDataRange().getValues();
  const headers = data[0];

  // Loop through each row (skip headers)
  for (let i = 1; i < data.length; i++) {
    const row = data[i];

    // Skip if doc already generated
    if (row[headers.indexOf('generatedLink')]) continue;

    // Build replacements
    const replacements = {};
    headers.forEach((h, idx) => {
      if (h !== 'generatedLink') {
        replacements[`\\[.*${h}.*\\]`] = row[idx];
      }
    });

    // Make copy of template
    const templateFile = DriveApp.getFileById(TEMPLATE_ID);
    const copyFile = templateFile.makeCopy(
      `Generated_${row[0]}_${new Date().toISOString().slice(0,10)}`,
      DriveApp.getFolderById(FOLDER_ID)
    );

    // Open and replace placeholders
    const doc = DocumentApp.openById(copyFile.getId());
    const body = doc.getBody();
    for (let tag in replacements) {
      body.replaceText(tag, replacements[tag]);
    }
    doc.saveAndClose();

    // Save link back into sheet
    sheet.getRange(i+1, headers.indexOf('generatedLink')+1)
      .setValue(copyFile.getUrl());
  }
}

Step 4 – Run the Script

  1. Replace YOUR_TEMPLATE_DOC_ID_HERE with your template’s Doc ID
  2. Replace YOUR_TARGET_FOLDER_ID_HERE with the ID of the folder where generated docs should be stored
  3. Run the script → authorize it → watch your documents appear!

Each row in your sheet now produces a new Google Doc, and the link is written back automatically:

Wrap Up

With just a few lines of code, you’ve upgraded your document automation:

  • One Google Doc becomes a reusable template
  • One Google Sheet becomes a data source
  • Each row produces a personalized document with its link logged back

This setup is basically a DIY mail-merge for Docs

In the next article, we’ll explore how to send those generated documents automatically by email, turning your workflow into a fully automated system.

Replace Tags in a Google Docs with Apps Script

Ever needed to prepare a contract, a template, or a letter where you just replace placeholders like [Client Name] or [Date] with real values?
Instead of manually editing each tag, we can automate the process with Google Apps Script.

This article shows you how to:

  • Add placeholders in your document
  • Replace them with real values in one click
  • Keep your templates clean and reusable

Why use placeholders?

Placeholders are short markers inside your document that can later be replaced with actual content.
For example:

With Apps Script, you can easily replace [name] with “Lidia”, or any other dynamic value from a sheet, a form, or an API.

Step 1 – Prepare your Google Docs

Open a new Google Docs file, or write docs.new in your search bar, and write some sample content with tags between square brackets [ ].

Step 2 – Open the Script Editor

  1. In your Google Doc, go to Extensions → Apps Script

2. Delete the default code and paste the following function:

function replaceAllPlaceholders() {
  const body = DocumentApp.getActiveDocument()
    .getActiveTab()
    .asDocumentTab()
    .getBody();
    
  const replacements = {
    '\\[.*name.*\\]': 'Lidia',
    '\\[.*date.*\\]': '16 Sept 2025',
    '\\[.*company.*\\]': 'Apps Script Lab'
  };
  
  for (let tag in replacements) {
    body.replaceText(tag, replacements[tag]);
  }
}

You can adapt the placeholders to match whatever data you need to inject.
Each occurrence of the placeholder will be swapped automatically using the replaceText() method.

Keep in mind: the function expects a regular expression as the search patter

Let’s make our template reusable

In this first example, we simply replaced the placeholders directly in the document.
But if you need to generate multiple documents while keeping your original file intact, it’s better to create a copy of the template and run the replacements in the copy.

/**
 * Creates a copy of the active Google Doc,
 * replaces placeholders in the copy,
 * and returns the URL of the generated document.
 */
function generateDocFromTemplate() {
  // Define placeholder values
  const replacements = {
    '\\[.*name.*\\]': 'Lidia',
    '\\[.*date.*\\]': '16 Sept 2025',
    '\\[.*company.*\\]': 'Apps Script Lab'
  };

  // Get the template (active document)
  const templateDoc = DocumentApp.getActiveDocument();
  const templateFile = DriveApp.getFileById(templateDoc.getId());

  // Create a copy next to the original
  const copyFile = templateFile.makeCopy(
    templateFile.getName() + ' - Generated ' + new Date().toISOString().slice(0, 10),
    templateFile.getParents().next() // same folder
  );

  // Open the copy for editing
  const copyDoc = DocumentApp.openById(copyFile.getId());
  const body = copyDoc.getBody();

  // Replace all placeholders
  for (let tag in replacements) {
    body.replaceText(tag, replacements[tag]);
  }

  // Save & close
  copyDoc.saveAndClose();

  // Return the URL (for logs or UI)
  Logger.log('Generated doc: ' + copyFile.getUrl());
  return copyFile.getUrl();
}

The script will create a new document based on your template.
Your original file remains untouched, and the placeholders are replaced only in the generated copy.

Much cleaner than editing manually, and your template is always safe for the next run.

Next Steps

In this tutorial, we learned how to:

  • Use placeholders in a Google Doc
  • Replace them automatically with Apps Script
  • Keep our original template safe by generating copies

The natural next step is to connect this process with Google Sheets.
That way, you can store your tags and values in a spreadsheet (e.g. [name], [date], [company]), run the script for each row, and automatically log the links to the generated documents back into the sheet.

This turns your simple template into a full document generation system

Stop Working with 2D Arrays in Google Sheets™ / Apps Script

If you’ve ever pulled data from a Google Sheet in Apps Script, you know the pain:
getValues() always gives you a 2D array.

[
  ["Name", "Email", "Role"],
  ["Alice", "alice@example.com", "Admin"],
  ["Bob", "bob@example.com", "Editor"],
  ["Charlie", "charlie@example.com", "Viewer"]
]

That format is fine for small scripts, but it quickly becomes messy.
You end up with unreadable code like:

const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
  const name = data[i][0];
  const email = data[i][1];
  const role = data[i][2];
  // ...
}

Hard to read. Easy to break. And if someone reorders the spreadsheet columns, your code will suddenly pull the wrong data.


A Cleaner Way: Convert to Array of Objects

Instead of juggling indexes, let’s wrap the data in a class that automatically maps each row into an object , using the first row of the sheet as headers.

class Data {
  constructor(sheetName) {
    this.spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    this.sheetName = sheetName;
    this.sheet = this.spreadsheet.getSheetByName(this.sheetName);

    if (!this.sheet) throw `Sheet ${this.sheetName} not found.`;

    const data = this.sheet.getDataRange().getValues();
    this.headers = data.shift();
    this.rows = data.map(row =>
      row.reduce((record, value, i) => {
        record[this.headers[i]] = value;
        return record;
      }, {})
    );
  }
}

Now, when we create a new Data instance:

function demo() {
  const users = new Data("Users");
  Logger.log(users.rows);
}

We can now have an array of objects as follow:

[
  { Name: "Alice",   Email: "alice@example.com",   Role: "Admin" },
  { Name: "Bob",     Email: "bob@example.com",     Role: "Editor" },
  { Name: "Charlie", Email: "charlie@example.com", Role: "Viewer" }
]

From here, working with the data is straightforward — no more i and j index gymnastics or confusing double loops!

Fix Chart Color Shuffle in Google Sheets using the Sheets API

Have you ever noticed that chart colors in Google Sheets sometimes shuffle around when your data changes? This usually happens when you build a chart on top of a pivot table. If one of the series (like a product or category) isn’t present in the filtered data, Google Sheets automatically re-assigns colors.

The result: one week “Surf Wax” is blue, the next week it’s orange, and your report suddenly looks inconsistent. Not great if you’re sending charts to clients or managers who expect a clean, consistent palette.

In this tutorial, we’ll fix the problem once and for all by locking chart colors using the Google Sheets API from Apps Script.

Why the colors shuffle

Charts in Google Sheets don’t store colors per label by default, they store them per series position. So if your chart shows [Surf Wax, Leash, Wetsuit] this week, those get assigned colors 0, 1, 2. But if next week [Pad] is missing, everything shifts left and the colors move with them.

That’s why you see colors “jump” around when some categories are empty.

The fix: define your own color map

The solution is to create a color map table in your sheet that tells Apps Script what color each item should always use.

Example in range P2:Q:

The trick is that we don’t actually use the text in the “Color” column, we use the cell background color. This makes it easy to set and change colors visually.

Using the Sheets API to lock colors

The built-in SpreadsheetApp class in Apps Script doesn’t let us fully control chart series. For that, we need the Advanced Sheets Service (a wrapper around the Google Sheets REST API).

To enable it:

  • In the Apps Script editor, go to Services > Add a service > turn on Google Sheets API v4.

The Apps Script solution

Here’s the script that applies your custom colors to the first chart on the Sales sheet:

/**
 * Apply chart colors from mapping in P2:Q to the first chart in "Sales".
 */
function updateChartColors() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("Sales");
  const spreadsheetId = ss.getId();

  // 1. Build color map (Item -> hex from background)
  const mapRange = sheet.getRange("P2:Q");
  const values = mapRange.getValues();
  const bgs = mapRange.getBackgrounds();

  const colorMap = {};
  values.forEach((row, i) => {
    const item = row[0];
    const color = bgs[i][1];
    if (item && color && color !== "#ffffff") {
      colorMap[item] = color;
    }
  });

  // 2. Get chart spec from Sheets API
  const sheetResource = Sheets.Spreadsheets.get(spreadsheetId, {
    ranges: [sheet.getName()],
    fields: "sheets(charts(chartId,spec))"
  }).sheets[0];

  if (!sheetResource.charts) return;
  const chart = sheetResource.charts[0];
  const chartId = chart.chartId;
  const spec = chart.spec;

  // 3. Apply colors by matching series headers with map
  if (spec.basicChart && spec.basicChart.series) {
    spec.basicChart.series.forEach((serie, i) => {
      const src = serie.series?.sourceRange?.sources?.[0];
      if (!src) return;

      // Get header label (like "Surf Wax", "Leash", etc.)
      const header = sheet.getRange(
        src.startRowIndex + 1,
        src.startColumnIndex + 1
      ).getValue();

      if (colorMap[header]) {
        spec.basicChart.series[i].colorStyle = {
          rgbColor: hexToRgbObj(colorMap[header])
        };
      }
    });
  }

  // 4. Push update back into the chart
  Sheets.Spreadsheets.batchUpdate(
    {
      requests: [
        {
          updateChartSpec: {
            chartId,
            spec
          }
        }
      ]
    },
    spreadsheetId
  );
}

/**
 * Convert #RRGGBB hex to Sheets API {red, green, blue}.
 */
function hexToRgbObj(hex) {
  const bigint = parseInt(hex.slice(1), 16);
  return {
    red: ((bigint >> 16) & 255) / 255,
    green: ((bigint >> 8) & 255) / 255,
    blue: (bigint & 255) / 255
  };
}

How it works

  1. Read the color map from P2:Q using the cell background color.
  2. Fetch the chart spec from the Sheets API.
  3. For each series, look up the header label from the pivot table (like “Wax Remover”).
  4. If it exists in the map, apply the mapped color using colorStyle: { rgbColor: … }.
  5. Push the modified spec back to the chart with updateChartSpec.

The result: your chart always uses the same colors, no matter which categories are present.

Wrapping up

With this approach, you’ll never have to worry about Google Sheets “helpfully” reshuffling your chart colors again. Once you set your palette in the sheet, a single click on a button can re-apply it to your chart. And because it uses the Sheets API directly, the colors are actually saved in the chart definition itself.

That means your reports stay consistent, your managers stop asking “why is Surf Wax orange this week?”, and your charts look clean and professional every time.

Create dynamic charts from Pivot Tables with Google Apps Script

In many reporting workflows, it’s helpful to send reports straight from a spreadsheet, complete with a chart inside the email. Imagine you’re running a surf shop with branches in different cities. Every week, the managers are waiting for their sales report: you filter the data for Sydney, copy the chart, paste it into an email, then repeat the same steps for Byron Bay and the Gold Coast. Before long, it feels like you’re stuck doing the same routine again and again.

Instead of preparing these reports by hand, the whole process can be automated. With a little help from Google Apps Script, the spreadsheet can filter the data for each city, update the chart, and send the report by email automatically.

In this tutorial, we will see how to:

  1. Extract a list of unique cities from the sales data.
  2. Apply a pivot filter dynamically for each city.
  3. Capture the chart as an image.
  4. Send a nicely formatted email with the chart embedded directly in the body.

We’ll go through the script function by function.

Here’s the Google Sheet we’ll be using in this tutorial, containing a simple dataset along with a pivot table and a chart.

1. Main function – looping through cities

Our entry point is the sendWeeklyReports() function.

function sendWeeklyReports() {
  // Getting sheet and data
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const salesSheet = ss.getSheetByName("Sales");
  const salesData = salesSheet.getDataRange().getValues();
  const headers = salesData.shift();

  // Extract cities
  const cities = salesData.map(x => x[headers.indexOf("City")]);

  // Get unique list of cities
  const unqiueCities = new Set(cities);

  // Get pivot table
  const pivotTable = salesSheet.getPivotTables()[0];

  // Get chart
  const chart = salesSheet.getCharts()[0];

  unqiueCities.forEach(city => {
    applyPivotFilter(city, pivotTable);
    const chartBlob = getPivotChartAsImage(chart, city);
    sendEmailReport(city, chartBlob);
  });
}

Explanation:

  • We grab the entire dataset from the Sales sheet.
  • From the data, we extract all values in the City column and convert them into a Set → this gives us a unique list of cities. This way, if a new city is added in the future (e.g. Melbourne), it’s automatically included.
  • We grab the pivot table and chart already created in the sheet.
  • Finally, we loop through each city, apply a filter, generate the chart image, and send the email.

This main function orchestrates the entire workflow.

2. Filtering the Pivot Table

We need to update the pivot table filter so that it only shows results for one city at a time.

function applyPivotFilter(city, pivotTable) {
  // Remove existing filter
  pivotTable.getFilters().forEach(f => f.remove());

  // Create a criteria for the city
  const criteria = SpreadsheetApp.newFilterCriteria().whenTextContains(city);
  pivotTable.addFilter(2, criteria);

  SpreadsheetApp.flush();
}

Explanation:

  • First, we clear existing filters. This ensures we don’t stack multiple filters and accidentally hide everything.
  • Then, we create a new filter criteria for the city.
  • The addFilter(2, criteria) call means “apply this filter on column index 2” (update this index if your “City” column is different).
  • Finally, we call SpreadsheetApp.flush() to force Sheets to update the pivot table immediately before we grab the chart.

This function makes the pivot behave as if you clicked the filter dropdown manually and selected just one city.

3. Exporting the Chart as an Image

Once the pivot table is filtered, the chart updates automatically.
We can then capture it as an image Blob.

function getPivotChartAsImage(chart, city) {
  return chart.getAs("image/png").setName(`Sales_${city}.png`);
}

Explanation:

  • chart.getAs("image/png") converts the chart object into an image blob.
  • We rename the file with the city name, which is handy if you want to attach it or archive it.

This blob can be sent in an email, saved to Drive, or inserted into a Slide deck.

4. Sending the Email

Finally, we embed the chart directly in an HTML email body.

function sendEmailReport(city, chartBlob) {
  const recipient = "manager@example.com";
  const subject = `Weekly Surfboard Report – ${city}`;

  const htmlBody = `
    <div style="font-family:Arial, sans-serif; color:#333;">
      <h2 style="color:#1565c0;">Weekly Surfboard Report – ${city}</h2>
      <p>Hello,</p>
      <p>Please find below the weekly sales chart for <b>${city}</b>:</p>
      <img src="cid:chartImage">
      <p style="margin-top:20px;">Cheers,<br>Your Automation Bot 🤖</p>
    </div>
  `;

  GmailApp.sendEmail(recipient, subject, "See the HTML version", {
    htmlBody: htmlBody,
    inlineImages: {
      chartImage: chartBlob
    }
  });
}

Explanation:

  • The htmlBody contains a nicely styled email.
  • <img src="cid:chartImage"> is a placeholder that’s replaced by the inline chart image.
  • In the sendEmail call, we pass both the HTML body and inlineImages: { chartImage: chartBlob }.
  • Result → the chart is embedded in the email itself, not just attached.

This makes the report feel polished and professional.

In conclusion, this workflow shows how easy it is to turn a manual reporting routine into a smooth, automated process. By letting Apps Script filter the pivot table, capture the chart, and send it directly inside an email, you get consistent weekly updates without lifting a finger. It’s a small piece of automation that saves time, reduces errors, and keeps your reports looking professional.

Automatically Email Google Form Submissions (Including File Uploads)

Ever wished you could get an instant email when someone submits your Google Form complete with their answers and any files they uploaded? With a few lines of Apps Script, you can!

Let’s break it down

Why Use This Script?

This solution answers a common need: getting notified via email when someone submits a form response. Especially useful when:

  • You’re collecting job applications or portfolios.
  • You want to automate intake notifications.
  • Your form includes file uploads, and you want the file attached in the email.
  1. Access Apps Script from your Google Forms
  2. Insert the snippet
  3. OnFormSubmit(e) trigger