How to display Google Sheets data on your website
A Google Sheet is often the easiest place to keep the data behind a web page: a price list, a team roster, opening hours, a product catalog, the posts of a small blog. The people who maintain that data already know how to edit a spreadsheet. The missing piece is getting the rows onto the page without a backend, a database or a Google Cloud project.
This guide shows how to do it with SheetDB, which turns a spreadsheet into a JSON REST API. You will render live sheet data as an HTML list and table with a single script tag, do the same with plain JavaScript fetch(), use a sheet as the CMS of a static site, and see how caching, filtering and refresh work. Every example runs against a real spreadsheet, this one, exposed at https://sheetdb.io/api/v1/58f61be4dda40. Open the API URL to see the JSON: an array of objects, one per row, keyed by the header row.
To use your own spreadsheet, sign in to SheetDB with Google, paste the spreadsheet URL and copy the API URL you get. The spreadsheet does not have to be public; SheetDB reads it with your account.
The fastest way: one script tag (Handlebars)
The SheetDB Handlebars library is a small script that finds every element with a data-sheetdb-url attribute, fetches the JSON from that URL and repeats the element's inner HTML once per row. Inside the template you refer to a column by its header name in double braces: {{name}}, {{age}}. No JavaScript of your own is needed.
<ul data-sheetdb-url="https://sheetdb.io/api/v1/58f61be4dda40">
<li>{{name}} is {{age}} years old</li>
</ul>
<script src="https://sheetdb.io/handlebars.js"></script>
This is what it renders on this page, unstyled:
- {{name}} is {{age}} years old
Three things to know before you go further:
- The first row of the sheet is the header. Its cells become the names you use in braces, so a column called
Product nameis{{Product name}}. {{name}}renders text only; any HTML stored in the cell is escaped. To render HTML from a cell, use{{html:name}}. The library then strips dangerous tags and attributes (script,iframe, inlineon*handlers,javascript:links) and keeps the rest.- Include
https://sheetdb.io/handlebars.jsonce per page, ideally at the end of<body>. It serves the current version; the full reference is in the Handlebars documentation.
Plain JavaScript fetch()
If you would rather not add a library, or you already render the page with your own JavaScript, the API is just JSON. A fetch() call and a loop that creates table rows is all it takes. Using textContent instead of innerHTML keeps whatever is typed into the sheet from being interpreted as markup.
<table id="people">
<thead>
<tr><th>ID</th><th>Name</th><th>Age</th><th>Comment</th></tr>
</thead>
<tbody></tbody>
</table>
<script>
fetch('https://sheetdb.io/api/v1/58f61be4dda40')
.then((response) => {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then((rows) => {
const tbody = document.querySelector('#people tbody');
rows.forEach((row) => {
const tr = document.createElement('tr');
['id', 'name', 'age', 'comment'].forEach((column) => {
const td = document.createElement('td');
td.textContent = row[column] ?? '';
tr.appendChild(td);
});
tbody.appendChild(tr);
});
})
.catch((error) => {
document.querySelector('#people').insertAdjacentHTML('afterend', '<p>Could not load the data.</p>');
console.error(error);
});
</script>
Live result of the code above:
| ID | Name | Age | Comment |
|---|
The API adds Access-Control-Allow-Origin: *, so the request works from any domain and from a page opened from disk. Values come back as strings because that is what a cell holds; add ?cast_numbers=id,age to the URL to get those columns as numbers. The same call works from Node.js, PHP or any other language, and the Read page in the docs lists every parameter.
Render an HTML table
A table is the most common way to show a sheet. With Handlebars, put the attributes on <tbody> and use one <tr> as the row template; the header stays ordinary HTML, so you control the column order and labels. This example sorts by age, oldest first, and shows a message when the sheet is empty:
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Age</th><th>Comment</th></tr>
</thead>
<tbody data-sheetdb-url="https://sheetdb.io/api/v1/58f61be4dda40"
data-sheetdb-sort-by="age"
data-sheetdb-sort-order="desc"
data-sheetdb-not-found-message="No rows yet.">
<tr>
<td>{{id}}</td>
<td>{{name}}</td>
<td>{{age}}</td>
<td>{{comment}}</td>
</tr>
</tbody>
</table>
| ID | Name | Age | Comment |
|---|---|---|---|
| {{id}} | {{name}} | {{age}} | {{comment}} |
data-sheetdb-url is the only required attribute. The others map one-to-one to the API parameters:
data-sheetdb-sheet– the name of the tab to read (letter case does not matter); the first tab is used by default.data-sheetdb-limitanddata-sheetdb-offset– how many rows to render and how many to skip, for pagination or a "top 5" list.data-sheetdb-sort-byanddata-sheetdb-sort-order– the column to sort by andasc,descorrandom. Adddata-sheetdb-sort-method="date"withdata-sheetdb-sort-date-formatto sort a date column correctly.data-sheetdb-searchanddata-sheetdb-search-mode– filter rows, see below.data-sheetdb-query-string– take the filter from the page URL, see the CMS section.data-sheetdb-not-found-message– text shown when no row matches.data-sheetdb-saveanddata-sheetdb-slot– reuse one response in several places.lazy-loading– fetch only when the element scrolls into view, useful for a long table below the fold.
Style the table like any other: the library only fills in rows, it does not add classes or CSS. If you use WordPress, the same attributes exist as shortcode parameters in the free SheetDB plugin, so you do not need to touch the theme.
Use a sheet as a CMS for a static site or blog
A spreadsheet works well as a lightweight CMS: one tab per content type, one row per item, one column per field. Editors change a cell, the site shows the new value on the next load, and nobody needs a login to an admin panel. Two features make this practical.
One row per URL with query strings
data-sheetdb-query-string="id" tells the library to read the id parameter from the page URL and search the sheet for it. Without the parameter no request is made and the element only shows the not-found message (or stays empty if you leave that attribute out), so a single product.html can serve every product. The example uses the large tab of the demo sheet, which has 1 999 rows:
<article data-sheetdb-url="https://sheetdb.io/api/v1/58f61be4dda40"
data-sheetdb-sheet="large"
data-sheetdb-query-string="id"
data-sheetdb-limit="1"
data-sheetdb-not-found-message="Nothing found for this id.">
<h3>{{name}}</h3>
<p>ID {{id}}, age {{age}}</p>
</article>
You can list several parameters separated by commas, for example data-sheetdb-query-string="category,brand", and combine them with a fixed data-sheetdb-search. A plain HTML form with method="get" is enough to let visitors pick the row.
Rich text from a cell
For a blog, keep a posts tab with slug, title, published and body columns, write the body as HTML (or paste it from an editor) and render it with the html: prefix. This snippet is for your own API, since the demo sheet has no posts tab:
<main data-sheetdb-url="https://sheetdb.io/api/v1/YOUR_API_ID"
data-sheetdb-sheet="posts"
data-sheetdb-query-string="slug"
data-sheetdb-limit="1"
data-sheetdb-not-found-message="Post not found.">
<h1>{{title}}</h1>
<p><em>{{published}}</em></p>
{{html:body}}
</main>
Two limits are worth knowing before you build a whole site this way. A Google Sheets cell holds at most 50 000 characters, which is a long article but not an unlimited one. And the content is rendered in the browser after the page loads: Google does index JavaScript-rendered content, but it happens later and less reliably than for HTML that is already in the page, and social previews and RSS readers see nothing. If search rankings for that content matter, fetch the JSON at build time instead and write it into the static HTML; every static site generator can do this in a data file or a build script:
// build.mjs – run with node build.mjs (Node.js 22+)
import { mkdir, writeFile } from "node:fs/promises";
const response = await fetch("https://sheetdb.io/api/v1/58f61be4dda40");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const rows = await response.json();
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
const html = rows.map((row) =>
`<tr><td>${escapeHtml(row.name)}</td><td>${escapeHtml(row.age)}</td></tr>`
).join("");
await mkdir("dist", { recursive: true });
await writeFile("dist/table.html", `<table>${html}</table>`, "utf8");
You get the same editing workflow, the content is in the HTML for crawlers, and the site only calls the API once per deploy. The guide on using Google Sheets as a database goes deeper into structuring the sheet, and the import JSON guide shows how to create the sheet from existing data.
Reuse one row in several places with slots
Add data-sheetdb-save="name" to an element and its rows are kept under that name. Any other element with data-sheetdb-slot="name" is then rendered from the same data, so a header, a sidebar and a footer can show fields of the same row with a single request:
<div data-sheetdb-url="https://sheetdb.io/api/v1/58f61be4dda40"
data-sheetdb-sheet="large"
data-sheetdb-search="id=18"
data-sheetdb-save="person">
<strong>{{name}}</strong> is {{age}} years old.
</div>
<!-- anywhere else on the page, rendered from the same response -->
<p data-sheetdb-slot="person">Welcome back, {{name}}!</p>
Welcome back, {{name}}!
Auto-refresh and caching
Every visit fetches the data, so a change in the sheet is on the site the next time someone loads the page. Between the sheet and the visitor sits the SheetDB cache: read responses are cached for 15 seconds by default, the duration is adjustable per API in its settings, and writes made through the API clear the cache immediately. The cache is what makes this safe for a busy page. Google throttles how often a spreadsheet can be read, and a landing page with thousands of visitors an hour would exhaust that quota within minutes if every view reached Google. With caching, only one request per cache period does. For a table that changes a few times a day, set the cache to several minutes; for a live scoreboard, keep the default.
If you edit the sheet by hand and want to see the change right away, purge the cache in the API settings or add ignore_cache=1 to a single fetch() URL while testing. Do not leave that parameter in production code: it sends every request to Google.
To update the page without a reload, the library exposes a global sheetdb_upd() function that fetches and re-renders every element again, and fires a sheetdb-downloaded event on each element and on window once everything is rendered. A timer is enough for a dashboard on a wall screen:
// re-render every element with data-sheetdb-url once a minute
setInterval(() => sheetdb_upd(), 60 * 1000);
// runs each time all elements on the page have been rendered
window.addEventListener('sheetdb-downloaded', () => {
console.log('Sheet data rendered');
});
Keep the interval at or above the cache duration; polling faster only returns the same cached rows. Each call counts against the monthly request quota of your plan, so a page that polls every minute uses 1 440 requests a day per open browser tab. The current quotas and rate limits are on the limits page.
Filter and search rows
You rarely want the whole sheet. data-sheetdb-search takes the same conditions as the API's search endpoint: column=value pairs joined with &. Values accept * as a wildcard, ! for negation and >, <, >=, <= for comparisons. All conditions must match:
<ul data-sheetdb-url="https://sheetdb.io/api/v1/58f61be4dda40"
data-sheetdb-search="age=>20&comment=special">
<li>{{name}} ({{age}}) – {{comment}}</li>
</ul>
- {{name}} ({{age}}) – {{comment}}
Add data-sheetdb-search-mode="or" to match rows that satisfy any of the conditions instead:
<ul data-sheetdb-url="https://sheetdb.io/api/v1/58f61be4dda40"
data-sheetdb-search="age=<16&comment=special"
data-sheetdb-search-mode="or">
<li>{{name}} ({{age}})</li>
</ul>
- {{name}} ({{age}})
With fetch() the same filter is a request to /search (or /search_or):
const params = new URLSearchParams({ age: '>20', comment: 'special' });
const rows = await fetch('https://sheetdb.io/api/v1/58f61be4dda40/search?' + params).then((r) => r.json());
// [{ "id": "4", "name": "Steve", "age": "22", "comment": "special" }]
For a search box, combine a GET form with data-sheetdb-query-string. The form reloads the page with ?name=Ste* and the list shows the matching rows; the wildcard makes it a "starts with" search. Try it:
<form>
<input type="text" name="name" placeholder="Name, e.g. Ste*">
<button type="submit">Search</button>
</form>
<ul data-sheetdb-url="https://sheetdb.io/api/v1/58f61be4dda40"
data-sheetdb-query-string="name"
data-sheetdb-not-found-message="No match.">
<li>{{name}} ({{age}})</li>
</ul>
* for everyone) and press Search.
Limitations: when not to display a sheet this way
- Private or per-user data. The API URL is visible in the page source and returns every column of the tab to anyone who calls it. Do not put customer lists, prices you negotiate individually or anything else confidential in a tab that a public page reads. If some visitors may see more than others, you need a backend that checks who they are.
- Large tables. A read returns the whole tab, or the rows matching the filter. A few hundred rows render instantly; tens of thousands make the page slow and the sheet slow to edit. Use
limitandoffsetfor pagination, or split the data over tabs. - Content that must rank in search. Rows rendered in the browser are indexed later and less reliably than static HTML. Fetch at build time for pages whose text is the reason people find them.
- Writing back from visitors. This guide covers reading. If visitors should add rows, for example through a contact or order form, see submitting an HTML form to Google Sheets and protect the write endpoints with HTTP Basic Auth.
For everything else, and that covers most tables, lists and small sites, keep the API read-only: in the API settings enable only GET and disable POST, PATCH and DELETE. A public URL that can only read is exactly what a website needs.
FAQ
Can I embed a Google Sheet as a table on my website?
Yes, in two ways. Google's own File → Share → Publish to web gives you an iframe that shows the raw spreadsheet grid: it always looks like a spreadsheet, you cannot style it and the whole sheet has to be public. With SheetDB the rows are delivered as JSON and rendered into your own HTML, so the table uses your fonts, colours and layout, you choose which columns to show, and the spreadsheet itself stays private.
Does the page update when the sheet changes?
Yes. The data is fetched when the page loads, so the next visitor sees the current rows. SheetDB caches read responses for 15 seconds by default (adjustable in the API settings), so an edit in the sheet shows up on the site within that time. To refresh without a page reload, call sheetdb_upd() from a timer or after an event on your page.
Do visitors see my spreadsheet?
They do not get the spreadsheet, but they can see the API. Anyone who opens your page source sees the API URL and can request the same JSON your page requests: every column of the tab you expose. Keep that tab free of anything private, put internal notes in another spreadsheet or tab, and switch the API to read-only (only GET enabled) so nobody can write through it.
Do I need to make the Google Sheet public?
No. SheetDB reads the sheet with the Google account you signed in with, so the spreadsheet can stay private and you never share it with "anyone with the link". Only the SheetDB API endpoint is public, and you decide which spreadsheet and which tabs it serves.
Is it free to display Google Sheets data on a website?
Google Sheets and the Handlebars library are free, and SheetDB has a free plan with a monthly request quota, no credit card required. Each element with data-sheetdb-url makes one request per page view, so a page with one table uses one request per visitor. Slots let you reuse the same rows in several places without extra requests. Paid plans start when your site outgrows the free quota.
Put your spreadsheet on your website
Sign in with Google, paste the spreadsheet URL and drop one script tag into your page. Free plan, no credit card, no backend.
Create free APIHave question?
If you have any questions feel free to ask us via chat or .