Google Apps Script Web App Tutorial: Build an Employee Directory (2026)
// Client-side (in your HTML file)
google.script.run
.withSuccessHandler(function(data) {
console.log('Got data:', data);
})
.withFailureHandler(function(error) {
console.error('Server error:', error);
})
.getEmployees();// Server-side (in Code.gs)
function getEmployees() {
const sheet = SpreadsheetApp.openById('YOUR_SHEET_ID').getSheetByName('Employees');
const data = sheet.getDataRange().getValues();
return data;
}The call is asynchronous. You pass success and failure handlers. The return value from the server function gets passed to your success handler. One catch: you can only pass simple types back and forth. Strings, numbers, arrays, plain objects. No Date objects, no custom classes, no functions.
Step 1: Create Your First Web App (Hello World)
Let's get something running.
- Go to script.google.com and click New Project
- Rename the project to "Employee Directory"
- Replace the contents of
Code.gswith thedoGetfunction above - Click the + next to Files, select HTML, and name it
index - Add this HTML:
<!DOCTYPE html>
<html>
<head>
<base target="_top">
</head>
<body>
<h1>Hello from Apps Script</h1>
<p>If you can see this, your web app is working.</p>
</body>
</html>- Click Deploy > Test deployments to get a test URL
- Open that URL in a new tab
You should see your heading. That <base target="_top"> tag is important. Apps Script serves your HTML inside an iframe, and without that base tag, links open inside the iframe instead of the full browser window.
Step 2: Build the Frontend With HTML and CSS
A bare HTML page isn't going to impress anyone. Let's add structure and styling.
Separating HTML, CSS, and JavaScript Files
Apps Script doesn't have native CSS or JS files. Everything is an .html file. But you can simulate separation by creating HTML files that contain only CSS or only JavaScript, then including them with a helper function.
Create a file called styles.html:
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
.search-bar {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 8px;
margin-bottom: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.card h3 { margin-bottom: 4px; }
.card p { color: #666; font-size: 14px; }
</style>Create scripts.html for client-side JS:
<script>
let allEmployees = [];
function loadEmployees() {
document.getElementById('employee-list').innerHTML =
'<div class="loading">Loading...</div>';
google.script.run
.withSuccessHandler(function(data) {
allEmployees = data;
renderEmployees(data);
})
.withFailureHandler(function(err) {
document.getElementById('employee-list').innerHTML =
'<div class="loading">Error loading data. Try refreshing.</div>';
})
.getEmployees();
}
function renderEmployees(employees) {
const container = document.getElementById('employee-list');
if (!employees.length) {
container.innerHTML = '<div class="loading">No employees found.</div>';
return;
}
container.innerHTML = employees.map(function(emp) {
return '<div class="card"><h3>' + emp.name + '</h3>' +
'<p>' + emp.department + ' · ' + emp.email + '</p></div>';
}).join('');
}
function filterEmployees() {
const query = document.getElementById('search').value.toLowerCase();
const filtered = allEmployees.filter(function(emp) {
return emp.name.toLowerCase().includes(query) ||
emp.department.toLowerCase().includes(query);
});
renderEmployees(filtered);
}
document.addEventListener('DOMContentLoaded', loadEmployees);
</script>Now add an include helper to Code.gs:
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename).getContent();
}And update index.html to pull everything together:
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<?!= include('styles'); ?>
</head>
<body>
<div class="container">
<h1>Employee Directory</h1>
<input type="text" id="search" class="search-bar"
placeholder="Search by name or department..."
oninput="filterEmployees()">
<div id="employee-list"></div>
</div>
<?!= include('scripts'); ?>
</body>
</html>The <?!= ?> tags are Apps Script's templating syntax. The ! means "don't escape the output" since we're injecting raw HTML/CSS/JS. For templating to work, update doGet:
function doGet(e) {
return HtmlService.createTemplateFromFile('index')
.evaluate()
.setTitle('Employee Directory')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}createTemplateFromFile instead of createHtmlOutputFromFile. The template version processes those tags before sending the HTML.
Using Bootstrap or Tailwind in Apps Script
You can load external CSS frameworks via CDN in your index.html head:
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">For internal tools, Bootstrap is usually the pragmatic choice. Good forms, tables, and modals without fighting the iframe sandbox.
Step 3: Connect Frontend to Google Sheets Backend
The employee directory needs actual data. Create a Google Sheet with these columns in row 1: Name, Department, Email, Phone, Start Date. Add some sample rows.
Copy the Sheet ID from the URL (the long string between /d/ and /edit).
Reading Data From Sheets
const SHEET_ID = 'YOUR_SHEET_ID_HERE';
function getEmployees() {
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Sheet1');
const data = sheet.getDataRange().getValues();
return data.slice(1).map(function(row) {
return {
name: row[0],
department: row[1],
email: row[2],
phone: row[3],
startDate: row[4] ? row[4].toLocaleDateString() : ''
};
});
}One thing to watch: getDataRange().getValues() reads the entire sheet at once. That's on purpose. One API call instead of reading cell by cell, which would be painfully slow.
Writing Data Back to Sheets
function addEmployee(employeeData) {
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Sheet1');
sheet.appendRow([
employeeData.name,
employeeData.department,
employeeData.email,
employeeData.phone,
new Date()
]);
return { success: true };
}Call it from the client side with google.script.run.addEmployee(formData) using the same success/failure handler pattern.
Step 4: Add User Authentication
Apps Script web apps have built-in auth that piggybacks on Google accounts. No OAuth libraries needed.
In your deployment settings, you choose who can access the app:
- Only myself (testing)
- Anyone within my Google Workspace domain (internal tools)
- Anyone, even anonymous (no login required)
For an internal employee directory, "Anyone within my domain" is the right choice. You can grab the user's email in server code:
function getCurrentUser() {
return { email: Session.getActiveUser().getEmail() };
}One gotcha. Session.getActiveUser().getEmail() only works when the app is deployed with "Execute as: User accessing the web app." If you deploy with "Execute as: Me," the active user is always you. The tradeoff: running as the user means the app only accesses data that user has permission to see. Running as yourself means the app accesses your data regardless of who's using it.
For most internal tools, deploy as yourself but access as domain users.
Step 5: Deploy Your Web App
Click Deploy > New deployment. Select Web app as the type.
Deployment Settings Explained
Execute as: Who the script runs as. "Me" means your permissions. "User accessing the web app" means their permissions.
Who has access: Controls who can open the URL. Pick based on your audience.
After deploying, you get a permanent URL like: https://script.google.com/macros/s/AKfycb.../exec
Testing vs. Production URLs
The test URL (from Deploy > Test deployments) always runs the latest saved code. The production URL runs whatever version you deployed. This trips people up constantly.
You make a code change, test it with the test URL, it works. But the production URL still shows old behavior because you forgot to create a new deployment. The workflow: edit code, test with test URL, then Deploy > Manage deployments > Edit > New version to push to production.
Debugging Apps Script Web Apps
Debugging is where this google apps script web app tutorial gets real. There's no console.log on the server side that shows up in your browser. Server errors sometimes surface as vague "Script function not found" messages.
Open Chrome DevTools and you'll see your app runs inside a sandboxed iframe. Client-side console.log works, but select the right execution context in the Console dropdown. Otherwise you're logging in the parent frame and nothing shows up.
Common Errors and Fixes
"Script function not found: doGet" means your function name is wrong or you haven't saved. Hit Ctrl+S explicitly.
"You do not have permission to call..." is a scope issue. Check your appsscript.json manifest for the required OAuth scopes.
"TypeError: Cannot read property 'getDataRange' of null" means getSheetByName() returned null because the sheet tab name you passed (e.g., 'Sheet1') doesn't match any tab in the spreadsheet. Double-check the exact name including capitalization and spaces.
White screen? Your doGet is probably throwing an unhandled error. Wrap it in try/catch during development:
function doGet(e) {
try {
return HtmlService.createTemplateFromFile('index')
.evaluate()
.setTitle('Employee Directory');
} catch (err) {
return HtmlService.createHtmlOutput('<pre>' + err.stack + '</pre>');
}
}For server-side logging, use Logger.log() and check Executions in the left sidebar.
Real-World Example: Employee Directory Web App
Here's the final Code.gs with caching added:
const SHEET_ID = 'YOUR_SHEET_ID_HERE';
function doGet(e) {
return HtmlService.createTemplateFromFile('index')
.evaluate()
.setTitle('Employee Directory')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename).getContent();
}
function getEmployees() {
const cache = CacheService.getScriptCache();
const cached = cache.get('employees');
if (cached) return JSON.parse(cached);
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Sheet1');
const data = sheet.getDataRange().getValues();
const employees = data.slice(1).map(function(row) {
return {
name: row[0],
department: row[1],
email: row[2],
phone: row[3],
startDate: row[4] ? new Date(row[4]).toLocaleDateString() : ''
};
});
cache.put('employees', JSON.stringify(employees), 300);
return employees;
}
function addEmployee(data) {
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Sheet1');
sheet.appendRow([data.name, data.department, data.email, data.phone, new Date()]);
CacheService.getScriptCache().remove('employees');
return { success: true };
}Notice the CacheService usage. We cache the employee list for 5 minutes. When someone adds a new employee, we invalidate the cache.
Performance Tips for Apps Script Web Apps
Apps Script has a 6-minute execution limit and Sheets slows down past a few thousand rows.
Batch all reads and writes. One getValues() call to read the whole range. One setValues() call to write. Never loop through cells individually.
Use CacheService. The script cache holds up to 100KB per key and persists for up to 6 hours. If your data doesn't change every request, cache it.
Move filtering and sorting to the client. Load the dataset once, let the browser handle search and pagination. Don't make a server round-trip for every keystroke.
Only return what the frontend needs. If your Sheet has 20 columns but the UI shows 5, map the data server-side before returning it.
Next Steps: What to Build Next
You've got a working web app pattern now. The same architecture works for dozens of internal tools. If you followed this google apps script web app tutorial from the start, you've covered doGet, HtmlService, Sheets as a backend, authentication, and deployment.
Some ideas to try next:
- Approval workflow. User submits a request, manager gets an email with approve/deny buttons that call back to your web app.
- Timesheet tracker. Drop-down for project codes, date picker, hours input. Data goes straight to Sheets.
- Inventory manager. Barcode scanning with the device camera, stock levels in Sheets, low-stock alerts via Gmail.
Once you're comfortable with the basics, try combining Apps Script with other Google services. Gmail automation, Calendar event creation, Drive file management. They all use the same server-side patterns you learned here. The official Apps Script documentation is solid for exploring those APIs.
The ceiling with Apps Script is lower than a full web framework. But the floor is so much higher. You can go from idea to deployed internal tool during a lunch break. For teams already in Google Workspace, that speed matters more than scaling to a million users.