← Google Apps Script

Advanced · A3

Web apps

A web app gives your script its own URL. Anyone you allow can open it as a web page or call it from another program. This site's own contact form sends its messages to an Apps Script web app, using the same doPost pattern you'll build here.

doGet: the front door

Start a standalone project at script.google.com, then:

Code.gs
// doGet runs when someone opens the web app's URL.
function doGet(e) {
  const name = e.parameter.name || 'visitor';      // ?name=Ploy in the URL
  return HtmlService.createHtmlOutput(`<h1>Hello, ${escapeHtml(name)}!</h1>`)
    .setTitle('My first web app');
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}

When a browser opens the URL, Google calls doGet(e). Whatever it returns (an HtmlOutput for a page, or a TextOutput for data) is what the visitor gets.

Part of eFor ...exec?name=Ploy&tag=a&tag=b
e.parameter{ name: "Ploy", tag: "a" } (first value of each)
e.parameters{ name: ["Ploy"], tag: ["a", "b"] }
e.queryStringname=Ploy&tag=a&tag=b
e.postData (doPost only){ contents, type, length }: the raw body

escapeHtml matters: the name comes from the URL, so anyone can put HTML in it. Escape anything from outside before you put it in a page.

Next: deploying →

Deploy it

  1. Click Deploy → New deployment. Click the ⚙ next to "Select type" and choose Web app.
  2. Description: "first version". Execute as: Me. Who has access: Only myself (for now).
  3. Click Deploy, authorize, and copy the Web app URL ending in /exec.
  4. Open it, then add ?name=Ploy to the end.
SettingChoices and meaning
Execute asMe: runs with your permissions for every visitor (can read your sheet). User accessing: each visitor signs in and the script uses their data.
Who has accessOnly myself · anyone in your school domain · anyone with a Google account · Anyone (no sign-in, needed for public forms and APIs)

/exec vs /dev, and versions

The /exec URL serves a fixed version. Saving new code does not change it. To publish changes: Deploy → Manage deployments → ✏ Edit → Version: New version → Deploy. The URL stays the same. Deploy → Test deployments gives a /dev URL that always runs your latest saved code, but only for you. Use it while developing.

Next: a real page →

A club page backed by a sheet

Make a sheet with an Events tab (Title, Date) and a Sign-ups tab. Put its ID in the code.

Code.gs
// A web page built from Index.html, with data from the sheet.
const SHEET_ID = 'PASTE_THE_SPREADSHEET_ID_HERE';   // web apps have no "active" sheet

function doGet() {
  const t = HtmlService.createTemplateFromFile('Index');
  t.events = getUpcoming();
  return t.evaluate()
    .setTitle('Club events')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');   // looks right on phones
}

function getUpcoming() {
  const rows = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Events')
    .getDataRange().getValues().slice(1);
  const tz = Session.getScriptTimeZone();
  return rows
    .filter((r) => r[1] instanceof Date && r[1] >= new Date())
    .map((r) => ({ title: r[0], when: Utilities.formatDate(r[1], tz, 'EEE d MMM') }));
}

// The page's form posts here through google.script.run.
function signUp(form) {
  const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Sign-ups');
  sheet.appendRow([new Date(), String(form.name).slice(0, 100), String(form.event).slice(0, 100)]);
  return 'Thanks, you are on the list!';
}
Index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style>
      body { font-family: Arial, sans-serif; max-width: 480px; margin: 24px auto; padding: 0 16px; }
      li { margin: 6px 0; }
      input, select, button { font: inherit; padding: 6px; margin: 4px 0; }
    </style>
  </head>
  <body>
    <h1>Coding club</h1>
    <ul>
      <? events.forEach((ev) => { ?>
        <li><b><?= ev.when ?></b> – <?= ev.title ?></li>
      <? }); ?>
    </ul>

    <form id="f">
      <input name="name" placeholder="Your name" required>
      <select name="event">
        <? events.forEach((ev) => { ?><option><?= ev.title ?></option><? }); ?>
      </select>
      <button>Sign up</button>
    </form>
    <p id="msg"></p>

    <script>
      document.getElementById('f').addEventListener('submit', (e) => {
        e.preventDefault();
        google.script.run
          .withSuccessHandler((m) => (document.getElementById('msg').textContent = m))
          .signUp(e.target);
      });
    </script>
  </body>
</html>
  • A web app isn't bound to anything, so getActiveSpreadsheet() doesn't work. Open the sheet by ID.
  • The template fills in the events on the server (A2's scriptlets), and google.script.run sends the sign-up back.
  • addMetaTag('viewport', …) is needed for the page to fit on phones.
  • The server shortens what it stores (slice(0, 100)), because anyone with the link can send anything.

Next: a JSON API →

Data for other programs

An API returns data instead of a page. ContentService sends plain text with a type, here JSON:

Code.gs
// A tiny JSON API: GET ...exec?room=M.5/3 returns that room's marks.
const SHEET_ID = 'PASTE_THE_SPREADSHEET_ID_HERE';

function doGet(e) {
  const room = e.parameter.room;
  const [header, ...rows] = SpreadsheetApp.openById(SHEET_ID)
    .getSheetByName('Marks').getDataRange().getValues();

  const students = rows
    .filter((r) => !room || r[1] === room)
    .map((r) => ({ name: r[0], room: r[1], mark: r[2] }));

  return json({ ok: true, count: students.length, students });
}

function json(obj) {
  return ContentService.createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}

Opening …/exec?room=M.5/3

{"ok":true,"count":2,"students":[{"name":"Ploy","room":"M.5/3","mark":72},{"name":"Mint","room":"M.5/3","mark":45}]}

Any website, app or script can now read your sheet through that URL:

any-page.html
<!-- On any web page: read the API -->
<script>
  const URL = 'https://script.google.com/macros/s/AKfy…/exec';

  fetch(URL + '?room=' + encodeURIComponent('M.5/3'))
    .then((r) => r.json())
    .then((data) => console.log(data.count, data.students));

  // Send to doPost. URL-encoded form data avoids an extra CORS "preflight" request.
  fetch(URL, {
    method: 'POST',
    body: new URLSearchParams({ name: 'Ploy', message: 'Hello!' }),
  })
    .then((r) => r.json())
    .then((res) => console.log(res.ok));
</script>

Redirects and CORS

Apps Script answers from script.googleusercontent.com after a redirect. Browsers and fetch follow it automatically. For POST from another site, send URL-encoded or plain-text bodies; a JSON Content-Type triggers a CORS preflight that Apps Script doesn't answer.

Next: doPost →

Receive data safely

A public doPost can be called by anyone, including bots. This version is the shape of a real contact-form relay:

Code.gs
// In the same project as the API above: it reuses SHEET_ID and json().
// Receive data from another website's form or from fetch().
function doPost(e) {
  try {
    // Form fields arrive in e.parameter; a JSON body arrives in e.postData.contents.
    const data = e.postData && e.postData.type === 'application/json'
      ? JSON.parse(e.postData.contents)
      : e.parameter;

    if (data.website) return json({ ok: true });              // honeypot: bots fill hidden fields
    const name = clean(data.name, 100);
    const message = clean(data.message, 2000);
    if (!name || !message) return json({ ok: false, error: 'Name and message are required.' });

    SpreadsheetApp.openById(SHEET_ID).getSheetByName('Messages')
      .appendRow([new Date(), name, message]);
    return json({ ok: true });
  } catch (err) {
    return json({ ok: false, error: 'Server error' });
  }
}

// Trim, shorten, and stop "=" "+" "-" "@" at the start from becoming a formula.
function clean(value, max) {
  const s = String(value || '').trim().slice(0, max);
  return /^[=+\-@]/.test(s) ? "'" + s : s;
}

🍯 Honeypot

The form has a hidden "website" field. People never fill it; bots do. Pretend success and store nothing.

✂️ Limit length

Shorten every value so nobody can fill your sheet with megabytes of text.

🧮 No formulas

Text starting with = + - @ gets an apostrophe so Sheets keeps it as text.

🧯 try / catch

Always return JSON, even on errors, so the caller can show a message.

Next: practice →

Practice

1. Dice API Easy

A web app that returns JSON like {"sides":20,"roll":13} for ?sides=20, defaulting to 6 and never allowing fewer than 2 or more than 100 sides.

Show a solution
Code.gs
function doGet(e) {
  const sides = Math.min(Math.max(parseInt(e.parameter.sides, 10) || 6, 2), 100);
  const roll = 1 + Math.floor(Math.random() * sides);
  return ContentService.createTextOutput(JSON.stringify({ sides, roll }))
    .setMimeType(ContentService.MimeType.JSON);
}
// ...exec?sides=20  →  {"sides":20,"roll":13}

2. Two pages Medium

Make one web app show Index.html normally and About.html for ?page=about. Add links between them using ScriptApp.getService().getUrl() and target="_top".

3. Guestbook Challenge

A public page that shows the last 10 messages from a sheet and has a form to add one. Use every protection from the doPost tab, and escape messages when you display them.

Next: quiz →

Check yourself

1. Which function runs when someone opens the web app URL in a browser?
2. You changed Code.gs, but the /exec link still shows the old version. Why?
3. "Execute as: Me" and "Who has access: Anyone" means…
4. How does doGet read ?room=M.5/3 from the URL?
5. To return JSON from a web app you use…
6. Why does clean() put a ' in front of text starting with = + - @?