Build a Web App with Google Apps Script: doGet, doPost, and HTML Service
Learn how to build and deploy a web app using Google Apps Script. Covers doGet, doPost, HtmlService, URL parameters, form submissions to Sheets, JSON APIs, templated HTML, and deployment permissions.
Most people think of Google Apps Script as a tool for automating spreadsheets -- write a function, attach a trigger, done. That is a fair starting point, but it misses one of the most underused capabilities on the platform: the ability to deploy a fully functional web application that anyone can access through a browser, with no server to provision, no hosting fees, and no infrastructure to maintain.
Apps Script's web app feature is built around two special functions -- doGet(e) and doPost(e) -- and a service called HtmlService that lets you serve real HTML pages. When you deploy a script as a web app, Google assigns it a public URL. Requests to that URL are routed to your doGet or doPost function, which can return a web page, a JSON response, or anything else an HTTP endpoint can deliver.
The practical applications are broad. A public feedback form that writes submissions directly to Google Sheets. An internal status dashboard that reads live data from a spreadsheet. A simple JSON API that external tools can call to look up data. A templated document generator that fills in a Google Doc based on URL parameters. All of these are buildable with Apps Script web apps in a few dozen lines of code.
This guide covers the full picture: the doGet and doPost functions in detail, serving HTML pages with HtmlService, handling URL parameters, building a form that writes to Sheets, creating JSON API endpoints, using templated HTML with scriptlets, managing deployment versions, and execution permissions. Three practical examples are included with copy-ready code.
How Web Apps Work in Apps Script
When a Google Apps Script project is deployed as a web app, Google creates an HTTPS endpoint -- a URL in the format https://script.google.com/macros/s/[DEPLOYMENT_ID]/exec. Every HTTP request to that URL is handled by one of two reserved function names in your script:
doGet(e)handles GET requests -- when a browser navigates to the URL, or when another system calls it with aGETmethod. This is used for serving pages and reading data.doPost(e)handles POST requests -- when a form is submitted, or when another system sends data using thePOSTmethod. This is used for receiving and processing submitted data.
Both functions receive a single parameter e, which is an event object containing everything about the incoming request: URL parameters, the request body, content type, and more. Both functions must return something -- either an HtmlOutput object (for serving HTML) or a TextOutput object (for JSON, plain text, or other formats).
If your script includes doGet but not doPost, GET requests will work and POST requests will return an error. Deploy only what you need.

How Apps Script routes incoming web requests through doGet and doPost
The doGet(e) Function
doGet(e) is the entry point for GET requests. The simplest possible implementation returns a plain text string:
function doGet(e) {
return ContentService.createTextOutput('Hello from Apps Script!');
}
Deploy this as a web app and visiting the URL will display that text in the browser. Not very useful on its own, but it confirms the deployment is working.
Serving an HTML Page
For anything more than plain text, you need HtmlService. The most direct way is to return an inline HTML string:
function doGet(e) {
var html = '<html><body><h1>Hello from Apps Script</h1><p>This is a web app.</p></body></html>';
return HtmlService.createHtmlOutput(html)
.setTitle('My Web App')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
HtmlService.createHtmlOutput() wraps the string in an HtmlOutput object that Apps Script knows how to serve as a proper HTML response. .setTitle() sets the browser tab title. .setXFrameOptionsMode(ALLOWALL) is required if you want to embed the app in an iframe (for example, inside a Google Site).
Reading URL Parameters
The e parameter in doGet(e) carries the incoming request data. For GET requests, URL parameters are available in e.parameter (a flat key-value object) and e.parameters (values as arrays, for repeated keys).
If the URL is https://script.google.com/macros/s/[ID]/exec?name=Sarah&team=Sales, then:
function doGet(e) {
var name = e.parameter.name; // 'Sarah'
var team = e.parameter.team; // 'Sales'
var html = '<h1>Welcome, ' + name + '</h1><p>Team: ' + team + '</p>';
return HtmlService.createHtmlOutput(html);
}
This pattern is useful for pre-filling forms (passing a customer ID or email address), for returning different views based on a query parameter, or for building a simple lookup tool where users bookmark personalised URLs.
Always validate and sanitise parameter values. Do not trust user input directly -- a malicious actor could craft a URL with unexpected parameter values.
The doPost(e) Function
doPost(e) handles HTTP POST requests. The most common trigger for a POST request is a form submission, but it also handles webhook payloads from external services.
For form submissions, form field values are available in e.parameter:
function doPost(e) {
var name = e.parameter.name;
var email = e.parameter.email;
var message = e.parameter.message;
// Process the data (e.g., write to a Sheet)
appendToSheet(name, email, message);
// Return a confirmation page
var html = '<html><body><h2>Thanks, ' + name + '!</h2>' +
'<p>Your message has been received.</p></body></html>';
return HtmlService.createHtmlOutput(html);
}
For JSON payloads (common when receiving data from external services or APIs), the body arrives as a raw string in e.postData.contents. Parse it with JSON.parse():
function doPost(e) {
var data = JSON.parse(e.postData.contents);
var result = processData(data);
return ContentService
.createTextOutput(JSON.stringify({ status: 'ok', result: result }))
.setMimeType(ContentService.MimeType.JSON);
}
HtmlService: Serving Pages from Separate Files
For anything beyond a trivial page, writing HTML as a JavaScript string becomes unwieldy quickly. The better approach is to create a separate .html file inside the Apps Script project and reference it by name.
In the Apps Script editor, click the "+" next to Files, select HTML, and name the file index. Do not include the .html extension -- the editor adds it automatically.
Then in your doGet function:
function doGet(e) {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('Feedback Form');
}
This loads the index.html file from your project and serves it as the response. The HTML file can contain a full page with CSS, JavaScript, and whatever structure you need.
Templated HTML with Scriptlets
HtmlService also supports a templating system similar to ERB or EJS. You can embed Apps Script code directly inside your HTML using scriptlet tags, which lets you inject server-side data into the page at render time.
There are three scriptlet types:
<? ... ?>-- executes code without outputting anything (useful for loops and conditionals)<?= ... ?>-- outputs the value of an expression (HTML-escaped)<?!= ... ?>-- outputs raw, unescaped HTML (use with caution)
Example: a page that displays a list of items from Google Sheets.
In your script file (Code.gs):
function doGet(e) {
var template = HtmlService.createTemplateFromFile('index');
// Pass data to the template
var sheet = SpreadsheetApp.openById('YOUR_SHEET_ID').getSheetByName('Products');
var data = sheet.getRange('A2:C' + sheet.getLastRow()).getValues();
template.products = data;
template.pageTitle = 'Product Catalogue';
return template.evaluate()
.setTitle(template.pageTitle);
}
In your index.html template file:
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="UTF-8">
<title><?= pageTitle ?></title>
</head>
<body>
<h1><?= pageTitle ?></h1>
<table>
<tr><th>Name</th><th>SKU</th><th>Price (AUD)</th></tr>
<? for (var i = 0; i < products.length; i++) { ?>
<tr>
<td><?= products[i][0] ?></td>
<td><?= products[i][1] ?></td>
<td>$<?= products[i][2].toFixed(2) ?></td>
</tr>
<? } ?>
</table>
</body>
</html>
HtmlService.createTemplateFromFile() loads the HTML file as a template object. You set properties on the template object (template.products, template.pageTitle) and those properties are available as variables inside the scriptlet tags. Calling .evaluate() renders the template and returns an HtmlOutput object ready to serve.
This approach separates your data logic (in Code.gs) from your presentation layer (in index.html), which makes both easier to maintain as your web app grows.

The Apps Script editor with a separate HTML template file and the resulting deployed web app
Practical Example 1: Public Feedback Form that Writes to Sheets
This is the most common Apps Script web app pattern -- a public form that anyone can fill in, with responses landing directly in a Google Sheet. No Google Forms required, and full control over the design and validation.
The Google Sheet needs to exist first. Create a sheet called "Responses" with headers in row 1: Timestamp, Name, Email, Rating, Message.
Code.gs:
var SHEET_ID = 'YOUR_SPREADSHEET_ID';
function doGet(e) {
return HtmlService.createHtmlOutputFromFile('feedback-form')
.setTitle('Feedback Form')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function doPost(e) {
var name = (e.parameter.name || '').trim();
var email = (e.parameter.email || '').trim();
var rating = (e.parameter.rating || '').trim();
var message = (e.parameter.message || '').trim();
// Basic validation
if (!name || !email || !rating) {
return HtmlService.createHtmlOutput(
'<p style="color:red">Required fields missing. Please go back and try again.</p>'
);
}
var sheet = SpreadsheetApp
.openById(SHEET_ID)
.getSheetByName('Responses');
sheet.appendRow([
new Date(),
name,
email,
Number(rating),
message
]);
return HtmlService.createHtmlOutput(
'<!DOCTYPE html><html><body style="font-family:sans-serif;padding:2rem">' +
'<h2>Thank you, ' + name + '!</h2>' +
'<p>Your feedback has been received. We appreciate you taking the time.</p>' +
'</body></html>'
);
}
feedback-form.html:
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feedback Form</title>
<style>
body { font-family: sans-serif; max-width: 540px; margin: 2rem auto; padding: 0 1rem; }
label { display: block; margin-top: 1.25rem; font-weight: 600; }
input, select, textarea {
width: 100%; padding: 0.5rem 0.75rem; margin-top: 0.35rem;
border: 1px solid #dadce0; border-radius: 4px; font-size: 1rem; box-sizing: border-box;
}
textarea { min-height: 120px; resize: vertical; }
button {
margin-top: 1.5rem; padding: 0.65rem 1.5rem; background: #1a73e8;
color: white; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer;
}
button:hover { background: #1557b0; }
.required { color: #d93025; }
</style>
</head>
<body>
<h1>Share Your Feedback</h1>
<form method="POST" action="<?= ScriptApp.getService().getUrl() ?>">
<label>Name <span class="required">*</span>
<input type="text" name="name" required>
</label>
<label>Email address <span class="required">*</span>
<input type="email" name="email" required>
</label>
<label>Rating <span class="required">*</span>
<select name="rating" required>
<option value="">-- Select --</option>
<option value="5">5 – Excellent</option>
<option value="4">4 – Good</option>
<option value="3">3 – Average</option>
<option value="2">2 – Poor</option>
<option value="1">1 – Very poor</option>
</select>
</label>
<label>Comments
<textarea name="message" placeholder="Tell us more (optional)"></textarea>
</label>
<button type="submit">Submit feedback</button>
</form>
</body>
</html>
One important note on the form action: ScriptApp.getService().getUrl() is a scriptlet that outputs the web app's own URL at render time. This ensures the form always posts to the correct deployment URL without you hardcoding it.
Practical Example 2: Simple Internal Status Tool
This example shows an internal tool that reads data from a Sheets-based status board and displays it as a dashboard, with URL parameter filtering so different teams can bookmark their own view.
function doGet(e) {
var filter = e.parameter.team || 'all';
var sheet = SpreadsheetApp
.openById('YOUR_SHEET_ID')
.getSheetByName('Status');
var raw = sheet.getDataRange().getValues();
var headers = raw[0];
var rows = raw.slice(1);
// Filter rows if a team parameter was provided
var filtered = (filter === 'all')
? rows
: rows.filter(function(row) {
return row[1].toLowerCase() === filter.toLowerCase();
});
var template = HtmlService.createTemplateFromFile('status-dashboard');
template.headers = headers;
template.rows = filtered;
template.team = filter;
template.updated = new Date().toLocaleString('en-AU');
return template.evaluate().setTitle('Status Dashboard');
}
The ?team=Engineering URL parameter allows engineers to bookmark a view filtered to their team. Managers can link to ?team=all for the full picture. The data stays in Sheets, the tool has no authentication overhead for internal access, and the URL is easy to drop into a Google Chat Space.
Practical Example 3: Data Lookup JSON API
Apps Script web apps are not limited to serving HTML. You can expose a JSON API endpoint that external tools -- dashboards, scripts, Zapier workflows, or other web apps -- can call via HTTP GET.
function doGet(e) {
var action = e.parameter.action;
// Route based on the 'action' parameter
if (action === 'lookup') {
return handleLookup(e);
} else if (action === 'list') {
return handleList();
} else {
return jsonResponse({ error: 'Unknown action. Use ?action=lookup or ?action=list' }, 400);
}
}
function handleLookup(e) {
var id = e.parameter.id;
if (!id) {
return jsonResponse({ error: 'Missing required parameter: id' }, 400);
}
var sheet = SpreadsheetApp
.openById('YOUR_SHEET_ID')
.getSheetByName('Data');
var data = sheet.getDataRange().getValues();
var headers = data[0];
// Find the row where column A matches the requested ID
var match = null;
for (var i = 1; i < data.length; i++) {
if (String(data[i][0]) === String(id)) {
match = {};
for (var j = 0; j < headers.length; j++) {
match[headers[j]] = data[i][j];
}
break;
}
}
if (!match) {
return jsonResponse({ error: 'Not found', id: id }, 404);
}
return jsonResponse({ data: match });
}
function handleList() {
var sheet = SpreadsheetApp
.openById('YOUR_SHEET_ID')
.getSheetByName('Data');
var raw = sheet.getDataRange().getValues();
var headers = raw[0];
var rows = raw.slice(1);
var result = rows.map(function(row) {
var obj = {};
headers.forEach(function(header, i) { obj[header] = row[i]; });
return obj;
});
return jsonResponse({ count: result.length, data: result });
}
// Helper: return a JSON response with the correct content type
function jsonResponse(obj, statusCode) {
// Note: Apps Script does not support custom HTTP status codes --
// the response always returns 200. Include the status in the body instead.
obj._status = statusCode || 200;
return ContentService
.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
Call the API from any tool that can make HTTP requests:
GET https://script.google.com/macros/s/[ID]/exec?action=list
GET https://script.google.com/macros/s/[ID]/exec?action=lookup&id=42
One important caveat: Apps Script web apps do not support custom HTTP response status codes. Every response comes back as HTTP 200, regardless of what happened. The convention is to include a _status field in the JSON body to indicate success or failure, and to document this for any consumer of the API.
Deploying as a Web App
Once your doGet or doPost function is written and tested, deploying the web app takes a few clicks.
Step 1: Open the deploy menu.
In the Apps Script editor, click Deploy in the top right corner, then select New deployment.
Step 2: Configure the deployment.
Click the gear icon next to "Select type" and choose Web app. Fill in the deployment description (for your own records -- useful when you have multiple versions deployed).
Step 3: Set execution and access permissions.
This is the most important decision in the deployment setup:
- Execute as: Me -- the script runs under your Google account. It has access to your Drive files, your spreadsheets, and your credentials. The user accessing the web app does not need to authenticate -- they interact with the script, which does the work as you. Use this for tools where users should be able to submit data without needing Google accounts, and where the data lands in your own spreadsheets.
- Execute as: User accessing the web app -- the script runs under the identity of the person who visits the URL. This requires the user to authenticate with Google. Their script actions are limited to what they have permission to do in their own Google account. Use this for internal tools where you want row-level access control, or where the script should read the current user's data.
- Who has access: Only myself -- useful during development and testing.
- Who has access: Anyone with Google account -- requires the user to sign in before accessing the app. Appropriate for internal company tools.
- Who has access: Anyone -- the app is publicly accessible without login. Required for public-facing forms, APIs, and any tool where anonymous access is needed.
Step 4: Authorise and deploy.
Click Deploy. If this is the first deployment, a permissions dialog will appear. Review the scopes your script is requesting and click Authorise. After authorisation, Google provides the web app URL. Copy it and test it in a browser.

The deployment configuration screen -- execution identity and access level are the two decisions that matter most
Versioning Deployments
Apps Script uses a versioning system that separates your development work from what is live in production.
Every time you click Deploy > New deployment, Google creates a new, immutable deployment with its own unique URL. That URL always serves the script as it was at the moment of deployment, regardless of any subsequent edits you make.
To update a live deployment, you must click Deploy > Manage deployments, then click the edit (pencil) icon next to the relevant deployment. Choose a new version or select "New version" from the dropdown, then click Deploy.
The key thing to understand is that the live deployment URL does not automatically update when you edit your script. You must explicitly publish a new version to push changes live. This is a feature, not a bug -- it means that in-progress edits cannot accidentally break a form or API that people are actively using.
During development, use Test deployments instead of managing new deployment versions constantly. A test deployment always runs the most recent saved version of your script, which is useful for iterating quickly. Find it under Deploy > Test deployments. Test deployments are not stable production URLs and should not be shared with end users.
Execution Permissions in Practice
Choosing between "Execute as: Me" and "Execute as: User accessing the web app" has real security and usability implications that are worth thinking through carefully.
Execute as Me is simpler to set up and is the right choice for most public-facing web apps. The trade-off is that you are granting the web app access to your own data under your credentials. If someone finds the URL, they can submit data that gets written to your spreadsheet. For a feedback form with a known, limited use case, this is usually fine. For anything that reads sensitive data, it is a risk.
Execute as User requires every visitor to sign in with a Google account. This eliminates the anonymous access risk but adds friction. It is the right choice for internal tools that should be restricted to your organisation's users, or for any tool that reads back data personal to the logged-in user.
For the "Execute as User" pattern, you can identify who is accessing the app with Session.getActiveUser().getEmail() inside your doGet or doPost function. This lets you personalise the response or log who submitted what.
function doGet(e) {
var userEmail = Session.getActiveUser().getEmail();
// userEmail will be empty if 'Who has access' is set to 'Anyone'
// It will be the user's email if set to 'Anyone with Google account'
// and the user is signed in
return HtmlService.createHtmlOutput(
'<p>You are signed in as: ' + (userEmail || 'anonymous') + '</p>'
);
}
Affiliate & Partner Programs
If you are building Apps Script web apps for your Australian business or building tools for clients, having the right Google Workspace plan in place makes a meaningful difference. Apps Script quotas -- execution time, URL fetch calls, email sends -- are more generous on paid Workspace plans than on free personal accounts.
Google Workspace is included in all paid plans and is the foundation for everything covered in this guide. If your team is not yet on a paid Workspace plan, or if you are evaluating the right tier, the referral link below covers current AUD pricing for Australian businesses.
Start or upgrade your Google Workspace plan -- current plans start from AUD $9.90 per user per month for Business Starter.
For Australian businesses using Apps Script web apps in production, Business Standard (AUD $19.80 per user per month) is typically the right tier. It increases Apps Script quotas, provides full Shared Drive support for shared script projects, and includes Gemini AI features that can be called from within Apps Script for content generation and analysis.
A note on what changes with paid plans for web app developers:
- Daily execution time per script: 6 hours (vs 1 hour on free accounts)
- URL fetch calls per day: 20,000 (vs 20,000 -- same, but rate limits differ)
- Email recipients per day: 1,500 (vs 500 on free)
- Trigger execution frequency: allows 1-minute time-driven triggers
For most web apps that handle form submissions or serve static-ish data, free account quotas are sufficient during development. As usage scales, Business Starter or Standard headroom starts to matter.
Common Issues and How to Fix Them
"Script function not found: doGet" -- this appears when the script has not been saved, or when there is a syntax error in Code.gs that prevents it from loading. Save the file (Ctrl+S) and check the Executions log for syntax errors before redeploying.
Changes not appearing after editing -- you edited the script but the live deployment URL is still serving the old version. You need to deploy a new version: click Deploy > Manage deployments, edit the deployment, and select "New version".
"Authorization is required to perform that action" -- the script is trying to access a resource (like a spreadsheet) that requires authorisation, but the deployment was not re-authorised after adding new capabilities. Delete the deployment, re-deploy, and re-authorise with the updated permissions.
CORS errors when calling the web app as an API -- Apps Script web app endpoints do not support CORS headers on doGet responses by default when called from external JavaScript in a browser. Apps Script web app URLs redirect, and the redirect strips custom headers. To work around this, call the web app from your server-side code rather than directly from browser JavaScript, or use the callback parameter for JSONP-style responses.
The form posts but nothing appears in the Sheet -- check the spreadsheet ID in your code. Open the spreadsheet, copy the ID from the URL (/d/[ID]/edit), and paste it into the SHEET_ID variable. Also verify the sheet tab name matches exactly (including capitalisation) what you have in getSheetByName().
Conclusion
Google Apps Script web apps remove the infrastructure layer entirely from small-to-medium web development. There is no server to configure, no domain to purchase, no SSL certificate to renew, no hosting bill to manage. You write doGet and doPost, deploy with three clicks, and have a live HTTPS endpoint that Google runs reliably on its own infrastructure.
The use cases this unlocks for Australian businesses are genuinely practical: a public feedback form that captures data into a spreadsheet your team already uses; an internal dashboard that reads live data without a separate database; a lightweight JSON API that lets your other tools query a Sheets-based dataset without a backend developer involved. For businesses already deep in Google Workspace, it is a natural extension of tools they are already paying for.
The limit is not capability -- it is familiarity. Once you have built one Apps Script web app end-to-end, the pattern becomes reusable. The doGet and doPost structure, the HtmlService template approach, the deployment and versioning flow -- these transfer directly to the next project. Start with the feedback form example, get it deployed, and then adapt the pattern to whatever your business actually needs.
Need help with your Google Workspace setup? Contact our team for a free consultation.
Ash Ganda is the founder of CloudGeeks, a Google Workspace consultancy helping Australian SMBs get more from their cloud tools. He writes about practical technology strategies at insights.cloudgeeks.com.au.