New: 6 free SQL practice datasets with 300+ questions — try the SQL Compiler →
Automations

Build a Web App Frontend With Apps Script

Build a web app frontend with Apps Script: HtmlService pages, google.script.run, include() files and a working task list on a Google Sheet.

Upskly AI Team September 26, 2026 13 min read
Build a Web App Frontend With Apps Script

An Apps Script web app can do more than return text or JSON: it can serve a real page with a form, a list and buttons, styled with CSS and driven by JavaScript in the visitor’s browser, all backed by your Google Sheet. Google calls the part that serves such pages the HTML Service. It is a quick way to give your team a small tool without hosting anything: a task tracker, a request form, a lookup page.

This post builds one small app from start to finish: a team task list that reads from a sheet and lets people add tasks. You will see how the server and the browser talk to each other with google.script.run, how to split the page into files, and the mistakes that trip up most beginners. It builds on What Is doGet in Apps Script? Explained, so read that first if doGet is new to you.

In this guide

The short version

  • doGet() returns an HtmlOutput made from an HTML file, so the visitor gets a real web page.
  • Code in the browser (HTML, CSS, JavaScript) and code on Google’s server (Apps Script) are two separate worlds. They talk through google.script.run.
  • google.script.run.serverFunction() is asynchronous: use withSuccessHandler and withFailureHandler, do not expect a return value.
  • Pass and return plain data only: numbers, strings, booleans, arrays and simple objects. A Date or a function fails.
  • Split big pages into files with an include() function, and load data after the page is shown.

How the results in this post were produced. Apps Script runs only on Google’s servers, so the exact code shown was run against a small simulation of the Sheets service (an in-memory sheet with the same method names). The logic of the script is real, but the sheet and the log are simulated, and the clock was fixed at Monday 16 March 2026, 09:30 India time. Always try a script on a copy of your own sheet first.

The browser part was simulated too: a tiny fake page and a fake google.script.run ran the page’s JavaScript against the server code. In a real browser the server calls are asynchronous, so there is a short pause before the list appears.

The two halves: server and browser

The most important idea is that your app has two halves that do not share anything:

Browser (the visitor's computer)              Google's server

 Index.html, Style.html, Script.html   ---- google.script.run ---->   Code.gs
  shows the page, runs on click          <--- success or failure ---    reads and writes the Sheet

The server half is normal Apps Script: it can open the spreadsheet, send mail and so on. The browser half is HTML, CSS and JavaScript, the same as on any website, but it cannot touch the spreadsheet. When the page needs data, it asks the server, using google.script.run, and the server function’s answer comes back to a function you provide. Google notes that the HTML Service runs pages in a sandbox, which restricts some HTML5 features, so keep the page simple.

The four files

In the editor, add HTML files with the plus button next to Files. Even a stylesheet or a script goes in an .html file, wrapped in <style> or <script> tags. Google’s advice is to avoid one giant file. We use four:

The project files
FileWhat it holds
Code.gsServer code: doGet(), include(), getTasks(), addTask()
Index.htmlThe page structure. It pulls in the other two files
Style.htmlThe CSS, inside a style tag
Script.htmlThe browser JavaScript, inside a script tag

Code.gs: the server side

Four functions. doGet() builds the page from the Index file as a template (so that scriptlets in it run) and gives it a title. include() returns the content of another HTML file, so Index.html can insert it. getTasks() reads the sheet and returns a list of plain objects. addTask() checks its input, writes a row, and returns the new list. Note that the server never trusts the browser: it validates the task itself:

const SHEET_ID = "YOUR_SPREADSHEET_ID";        // the long id in the sheet's URL

function doGet() {
  return HtmlService.createTemplateFromFile("Index")
      .evaluate()
      .setTitle("Team tasks");
}

// lets Index.html pull in Style.html and Script.html
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

// called from the page: returns plain data only
function getTasks() {
  const rows = SpreadsheetApp.openById(SHEET_ID).getSheetByName("Tasks")
      .getDataRange().getValues().slice(1);                 // drop the header
  return rows.map(row => ({ task: row[0], status: row[1], owner: row[2] }));
}

// called from the page: validates, writes, and returns the fresh list
function addTask(task, owner) {
  const clean = String(task).trim();
  if (!clean) throw new Error("Please enter a task");
  SpreadsheetApp.openById(SHEET_ID).getSheetByName("Tasks")
      .appendRow([clean, "Open", String(owner).trim() || "Unassigned"]);
  return getTasks();
}

Server checks (simulated)

Page title: Team tasks
Style included: true
Script included: true
Scriptlets left in the page: false

The page came out complete: the title is set, the style and script files were included, and no scriptlet was left unprocessed.

Index.html, Style.html and Script.html

The page starts with <!DOCTYPE html> so that the browser uses modern rendering. The two lines with <?!= include(...); ?> are scriptlets: when the page is built on the server, each one is replaced with the file it names. (The force-printing form <?!= ?> is right here, because the included content is your own trusted HTML. For anything a visitor supplies, use <?= ?>, which escapes it.) The script is placed at the end of the page, so the HTML shows up before the JavaScript runs:

<!DOCTYPE html>
<html>
  <head>
    <?!= include("Style"); ?>
  </head>
  <body>
    <h1>Team tasks</h1>
    <ul id="list"><li>Loading...</li></ul>

    <form id="form">
      <input id="task" placeholder="New task">
      <input id="owner" placeholder="Owner">
      <button type="submit">Add</button>
    </form>
    <p id="message" class="error"></p>

    <?!= include("Script"); ?>
  </body>
</html>

The stylesheet is plain CSS:

<style>
  body { font-family: sans-serif; max-width: 480px; margin: 24px auto; }
  li { padding: 4px 0; }
  .error { color: #b00020; }
</style>

The browser script does three things: render() draws the list, showError() displays a message, and a submit handler calls the server. At the bottom it asks the server for the list as soon as the page has loaded:

<script>
  function render(tasks) {
    const list = document.getElementById("list");
    list.innerHTML = "";
    tasks.forEach(function (t) {
      const li = document.createElement("li");
      li.textContent = t.task + " (" + t.status + ", " + t.owner + ")";   // textContent is safe
      list.appendChild(li);
    });
    document.getElementById("message").textContent = "";
  }

  function showError(error) {
    document.getElementById("message").textContent = error.message;
  }

  document.getElementById("form").addEventListener("submit", function (event) {
    event.preventDefault();
    google.script.run
        .withSuccessHandler(render)
        .withFailureHandler(showError)
        .addTask(document.getElementById("task").value,
                 document.getElementById("owner").value);
  });

  // load the list after the page is shown
  google.script.run.withSuccessHandler(render).withFailureHandler(showError).getTasks();
</script>

How the browser calls the server

google.script.run is Google’s bridge. Calling google.script.run.addTask("Pay rent", "Chen") runs the server function addTask with those arguments. Three rules:

  1. It is asynchronous. The browser does not wait: the line after the call runs immediately, and the answer arrives later. So you cannot write const tasks = google.script.run.getTasks(). Instead, attach a withSuccessHandler(fn), and Google calls fn with the return value.
  2. Failures need their own handler. If the server function throws an error, withFailureHandler(fn) is called with the Error object, and error.message holds your message.
  3. Only plain data travels. Arguments and return values may be numbers, booleans, strings, null, and objects or arrays made of them (and a form element from the page). Google’s documentation says requests fail if you pass a Date, a function or another DOM element. Convert dates to strings on the server first.

The app in action

Here is a session, simulated. The sheet has two tasks. The page loads and shows them. The user types “Pay rent” with owner “Chen” and presses Add, and the list gains a third task. Then they press Add with an empty task, and the server’s validation message appears. Nothing was added to the sheet:

What the page showed (simulated)

On load:       Write report (Open, Asha) | Send invoice (Done, Ben)
After adding:  Write report (Open, Asha) | Send invoice (Done, Ben) | Pay rent (Open, Chen)
Error message: Please enter a task
Tasks sheet after the session (simulated sheet)
ABC
1TaskStatusOwner
2Write reportOpenAsha
3Send invoiceDoneBen
4Pay rentOpenChen

Templates or google.script.run?

There are two ways to get data into a page. A template scriptlet runs on the server when the page is built and can insert values straight into the HTML. google.script.run fetches the data after the page is already showing. Google’s guidance is to use scriptlets only for quick, one-time jobs such as including other files or setting fixed values, and to load all other data with google.script.run. The reason: a template’s code runs to completion before anything is sent to the browser, so slow work in a template leaves the visitor looking at a blank screen. Our page shows “Loading…” at once and fills the list when the data arrives.

Security

  • Show visitor text with textContent, not innerHTML. If a task were <img src=x onerror=...>, innerHTML would run it, while textContent just displays it.
  • Validate on the server. Anyone can call your server functions with any argument, not just your page. addTask() trims and checks its input itself.
  • Nothing in the page is secret. The browser downloads your HTML and JavaScript, so never put keys or private data in them.
  • Mind the deployment settings. “Execute as me” with “Anyone” lets strangers use your permissions through your functions. See Apps Script Deployment Types Explained.
  • Return only what the page needs. getTasks() returns three fields, not the whole sheet.

Publishing it

Nobody can open the app until you deploy it: Deploy, then New deployment, choose Web app, set who can use it, and copy the URL that ends in /exec. While developing, use a test deployment: its /dev URL, which only editors can open, always runs your latest saved code. After you change the code, the /exec URL keeps serving the old version until you create a new version and update the deployment. That last step is the one people forget, and it is explained in the deployment post.

Common mistakes

  • Expecting a return value from google.script.run. It returns immediately. The answer arrives in the success handler.
  • Returning a Date or a class instance. Convert to strings and plain objects on the server.
  • No failure handler. When the server throws, nothing happens on the page, and the user thinks the app froze.
  • Putting <script> in the head. The elements do not exist yet, and getElementById returns null. Put it at the end of the body.
  • Loading big data in a template. The page stays blank until the template finishes. Load it with google.script.run.
  • Forgetting the include() function. <?!= include("Style"); ?> fails if the function is not in Code.gs.
  • File names. include("style") does not find Style.html. The name must match exactly, without the extension.
  • Not redeploying. Your changes are visible on /dev but not on /exec until a new version is deployed.

Try it yourself

Work out each answer first, then open the solution.

1. Write a server function countOpen() that returns the number of open tasks, and show it on the page as “1 open”.

Show solution
function countOpen() {
  const rows = SpreadsheetApp.openById("YOUR_SPREADSHEET_ID")
      .getSheetByName("Tasks").getDataRange().getValues().slice(1);
  return rows.filter(row => row[1] === "Open").length;
}

What the page showed (simulated)

The page shows: 1 open

The server returns a plain number. The success handler writes it into the page. The page never touches the sheet.

2. A sheet has a due-date column. Return the tasks and their due dates to the page.

Show solution
function getDueDates() {
  const rows = SpreadsheetApp.openById("YOUR_SPREADSHEET_ID")
      .getSheetByName("Due").getDataRange().getValues().slice(1);
  return rows.map(row => ({
    task: row[0],
    due: Utilities.formatDate(row[1], Session.getScriptTimeZone(), "yyyy-MM-dd")   // a string, not a Date
  }));
}

Server result (simulated)

[{"task":"Write report","due":"2026-03-20"}]

A Date cannot be sent to the browser, so the server formats it into a string first.

3. Why is li.textContent = ... safer than li.innerHTML = ... in render()?

Show answer

textContent treats the value as plain text, so a task such as <script>...</script> is only displayed. innerHTML parses the value as HTML and may run it, which lets a visitor inject code into your page.

4. Your page shows “Loading…” forever. What are the first two things to check?

Show answer

First, whether the server function threw an error and there is no failure handler (add withFailureHandler and look at Executions). Second, whether the function returned something that cannot be sent, such as a Date, which makes the call fail.

Frequently asked questions

How do I build a frontend for Google Apps Script?

Add HTML files to your project, return one from doGet() with HtmlService.createTemplateFromFile("Index").evaluate(), and deploy the script as a web app. In the page, use google.script.run to call your server functions.

What is google.script.run?

It is the client-side JavaScript API that lets a page served by Apps Script call server-side functions. Calls are asynchronous, and results come back through withSuccessHandler and withFailureHandler.

How do I include CSS and JavaScript files in an Apps Script web app?

Put them in HTML files (inside style and script tags), add an include(filename) function that returns HtmlService.createHtmlOutputFromFile(filename).getContent(), and insert them in the page with <?!= include("Name"); ?>.

Why does my google.script.run call return undefined?

Because the call is asynchronous. It does not return the server’s result. Pass a function to withSuccessHandler() to receive the result.

What can I pass between the browser and the server?

Numbers, booleans, strings, null, and objects and arrays made of them. Passing a Date, a function or a DOM element (other than a form) makes the call fail.

Can I use React or another framework in an Apps Script web app?

The HTML Service serves ordinary HTML, CSS and JavaScript, and libraries served over HTTPS can be loaded in the page, for example jQuery. Bigger frameworks need a build step and are outside the scope of this guide.

Upskly AI Team
Learning made simple
Scroll to Top