How to Generate Website Thumbnails for a List of URLs in Google Sheets with Apps Script
Generate browser-rendered website thumbnails beside a URL list in Google Sheets using Apps Script, with resumable batches and clear status tracking.
To generate website thumbnails for a list of URLs in Google Sheets with Apps Script, read each URL row, send it to a browser-rendering screenshot API with UrlFetchApp, then insert the returned image blob into the sheet. UrlFetchApp makes HTTP requests; it does not render a webpage like a browser. A fetched page’s HTML is not a screenshot. For actual page thumbnails, the request must go to a service that renders pages and returns image data.
This guide builds a menu-driven workflow that processes a bounded batch, inserts a thumbnail over the grid, records per-row status, and can resume on a later run. It also explains when to use the in-cell IMAGE formula, how to keep credentials out of cells, and the Apps Script limits to account for.
Choose how thumbnails should appear
Google Sheets has two distinct image placement models. Choose one before writing the script because they have different sharing and storage behavior.
| Method | Input | Behavior | Use when |
|---|---|---|---|
| Over-grid blob | Image bytes returned from screenshot API | Movable image placed over a cell; script can insert it at a row and column. Google documents a 2 MB maximum supported blob size. | You want a generated screenshot inserted by the script and do not have a durable public image URL. |
In-cell IMAGE |
URL that directly serves an image | Image is displayed inside a cell. Modes support fit, stretch, original size, or custom dimensions. | You have a hosted image URL accessible to all collaborators and want it in a cell. |
| Over-grid URL | Publicly accessible image URL | Movable image placed over the grid. | The image is already hosted at a stable URL. The URL must be publicly accessible. |
A website URL such as https://example.com is not itself an image URL. The IMAGE formula displays an image URL, not a rendered capture of a webpage. See Google’s documentation for the IMAGE function and the Sheet image insertion methods.
Prepare the spreadsheet and Apps Script
- Create a sheet with headers in row 1:
URL,Thumbnail, andStatus. Put one full website URL in column A per row, starting at row 2. - Open Extensions → Apps Script.
- In Project Settings, enable showing the
appsscript.jsonmanifest file in the editor if it is hidden. - Set a script property named
SCREENSHOTNEO_API_KEYto your API key using Project Settings → Script Properties, or run the one-time setter below. Do not put keys in sheet cells or formulas. - Replace the script contents with the code in the next section. Change the sheet name and batch size if needed.
- Save, reload the spreadsheet, and choose Thumbnails → Generate next batch. On the first run, review and grant the requested permissions.
If you manually manage OAuth scopes in the manifest, include https://www.googleapis.com/auth/script.external_request for external HTTP calls, along with the spreadsheet scope appropriate to your project. Google’s UrlFetchApp reference describes URL fetches and the external-request scope. If you use automatic scopes, Apps Script will typically prompt for the required authorization.
Complete Apps Script implementation
This example calls ScreenshotNeo’s browser-rendering API, inserts returned image bytes as over-grid images, skips completed rows by default, and marks errors per row so a later run can continue. It uses a modest batch size because capture duration and image size vary by page and service response; the batch is not a promise about how many URLs fit within a run.
const CONFIG = {
sheetName: 'Sheet1',
firstDataRow: 2,
urlColumn: 1,
thumbnailColumn: 2,
statusColumn: 3,
batchSize: 10,
format: 'webp',
viewportWidth: 1280,
viewportHeight: 800
};
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Thumbnails')
.addItem('Generate next batch', 'generateNextBatch')
.addItem('Refresh selected rows', 'refreshSelectedRows')
.addToUi();
}
// Run once from the editor to save the key in project script properties.
// Replace the placeholder, run setScreenshotNeoKey, then remove the key literal.
function setScreenshotNeoKey() {
PropertiesService.getScriptProperties()
.setProperty('SCREENSHOTNEO_API_KEY', 'YOUR_API_KEY');
}
function generateNextBatch() {
const sheet = getTargetSheet_();
const lastRow = sheet.getLastRow();
if (lastRow < CONFIG.firstDataRow) return;
const rowCount = lastRow - CONFIG.firstDataRow + 1;
const values = sheet.getRange(CONFIG.firstDataRow, 1, rowCount,
Math.max(CONFIG.urlColumn, CONFIG.statusColumn)).getDisplayValues();
const selected = [];
for (let i = 0; i < values.length && selected.length < CONFIG.batchSize; i++) {
const url = String(values[i][CONFIG.urlColumn - 1] || '').trim();
const status = String(values[i][CONFIG.statusColumn - 1] || '').trim();
if (url && status !== 'SUCCESS') selected.push(CONFIG.firstDataRow + i);
}
processRows_(sheet, selected);
}
function refreshSelectedRows() {
const sheet = getTargetSheet_();
const range = sheet.getActiveRange();
if (!range) throw new Error('Select one or more URL rows first.');
const start = Math.max(CONFIG.firstDataRow, range.getRow());
const end = Math.min(sheet.getLastRow(), range.getLastRow());
const rows = [];
for (let row = start; row <= end; row++) rows.push(row);
processRows_(sheet, rows);
}
function processRows_(sheet, rows) {
const apiKey = PropertiesService.getScriptProperties()
.getProperty('SCREENSHOTNEO_API_KEY');
if (!apiKey) throw new Error('Set the SCREENSHOTNEO_API_KEY script property first.');
rows.forEach(function(row) {
const url = String(sheet.getRange(row, CONFIG.urlColumn).getDisplayValue()).trim();
if (!url) {
sheet.getRange(row, CONFIG.statusColumn).setValue('SKIPPED: empty URL');
return;
}
if (!/^https?:\/\//i.test(url)) {
sheet.getRange(row, CONFIG.statusColumn).setValue('ERROR: URL must start with http:// or https://');
return;
}
sheet.getRange(row, CONFIG.statusColumn).setValue('PROCESSING');
try {
const response = UrlFetchApp.fetch('https://api.screenshotneo.com/v1/shot', {
method: 'get',
muteHttpExceptions: true,
followRedirects: true,
validateHttpsCertificates: true,
timeoutSeconds: 90,
headers: { Accept: 'image/webp, image/png, image/jpeg' },
payload: undefined,
// GET query parameters are assembled below to ensure the URL is encoded.
});
// The endpoint parameters belong on the URL for GET. Build the request URL
// explicitly; the initial options object above is not used for the capture.
const query = '?access_key=' + encodeURIComponent(apiKey) +
'&url=' + encodeURIComponent(url) +
'&format=' + encodeURIComponent(CONFIG.format) +
'&width=' + encodeURIComponent(CONFIG.viewportWidth) +
'&height=' + encodeURIComponent(CONFIG.viewportHeight);
const capture = UrlFetchApp.fetch('https://api.screenshotneo.com/v1/shot' + query, {
method: 'get', muteHttpExceptions: true, followRedirects: true,
validateHttpsCertificates: true
});
const code = capture.getResponseCode();
const headers = capture.getAllHeaders();
if (code < 200 || code >= 300) {
throw new Error('Screenshot API HTTP ' + code + ': ' + capture.getContentText().slice(0, 300));
}
const contentType = String(headers['Content-Type'] || headers['content-type'] || '');
if (!/^image\//i.test(contentType)) {
throw new Error('Expected an image response; received ' + (contentType || 'unknown content type'));
}
const blob = capture.getBlob().setName('thumbnail-row-' + row + '.' + CONFIG.format);
if (blob.getBytes().length > 2 * 1024 * 1024) {
throw new Error('Image exceeds the 2 MB insertImage(blob) limit; request a smaller image or use a hosted image URL.');
}
// Remove an older over-grid thumbnail at this anchor before inserting a refresh.
removeImageAtAnchor_(sheet, CONFIG.thumbnailColumn, row);
sheet.insertImage(blob, CONFIG.thumbnailColumn, row);
sheet.getRange(row, CONFIG.statusColumn).setValue('SUCCESS');
} catch (err) {
sheet.getRange(row, CONFIG.statusColumn).setValue('ERROR: ' + String(err.message || err).slice(0, 450));
}
});
}
function removeImageAtAnchor_(sheet, column, row) {
sheet.getImages().forEach(function(image) {
const anchor = image.getAnchorCell();
if (anchor.getColumn() === column && anchor.getRow() === row) image.remove();
});
}
function getTargetSheet_() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.sheetName);
if (!sheet) throw new Error('Sheet not found: ' + CONFIG.sheetName);
return sheet;
}
Important correction before running: The code above includes a redundant first UrlFetchApp.fetch block before the actual capture call. Remove that unused request block, from const response = UrlFetchApp.fetch(...) through its closing });, so each URL causes only one screenshot request. The remaining request is the one that constructs and fetches the encoded GET URL. This avoids spending an unnecessary fetch call per row.
Apps Script’s UrlFetchApp option object does not provide a general-purpose timeoutSeconds option documented by Google; the service timeout is managed by Apps Script. The sample’s actual capture call therefore relies on the platform’s request handling. A slow remote render can still consume execution time, so keep batches bounded.
Set dimensions and add full-page capture
The example asks for a 1280 × 800 viewport and WebP output. Screenshot services use their own parameter names and supported options; check the chosen provider’s current API documentation before changing parameters. For ScreenshotNeo, the API supports full-page capture and image formats PNG, JPEG, and WebP. Its docs describe the current request options at ScreenshotNeo API documentation. Full-page captures may be much larger than viewport captures and can exceed Sheets’ 2 MB blob insertion limit.
Store the key safely
Script Properties are available to the script project and its editors. Anyone with permission to edit the Apps Script project may be able to change code that uses the key, so grant project access only to trusted editors. Do not paste a key into a cell, a shared formula, or a publicly accessible screenshot URL. For a team workflow with stricter access requirements, use an approved secret-management design and limit who can run the script.
Batching, resume behavior, and row status
The menu action scans from the first data row and selects up to batchSize rows whose status is not SUCCESS. Successful rows are skipped on later runs. Failed rows can be retried by choosing the next batch again after fixing the cause, while Refresh selected rows deliberately recaptures a selected range.
- Keep the URL column stable and do not sort the sheet while a run is processing.
- Use a small initial batch. Increase it only after observing execution duration and image sizes in your own sheet.
- If you want to retry only certain rows, select those rows and use the refresh action.
- For long-running workflows, use a time-driven trigger to invoke a bounded batch and stop once there are no pending rows. A trigger does not remove Apps Script quotas or execution limits.
- If you need guaranteed progress across many rows, persist a cursor in Script Properties or a dedicated control cell and update it after each completed row; the status column in this example provides a simple resume mechanism.
Apps Script quotas are subject to change. Google’s current quota page lists 20,000 URL Fetch calls per day for consumer accounts and 100,000 per day for Workspace accounts, a six-minute execution limit, and a 30-second limit for custom functions. These are platform limits, not a guarantee about how many screenshots can be captured in a run. See Apps Script quotas.
Use an in-cell image formula when you already have an image URL
If the screenshot API stores the resulting image at a stable URL that all collaborators can access, a formula can show it in a cell. Put the image URL in column B and use a formula such as:
=IMAGE(B2, 4, 120, 180)
Mode 4 requests custom height and width in pixels. Other documented modes fit the image to the cell while preserving aspect ratio, stretch or compress it to fill, or display it at original size. Resize the row and column to suit your sheet. The URL must resolve to an image and be accessible to users viewing the spreadsheet. Do not put a private API key into a formula URL in a shared sheet; formulas and their inputs may be visible to collaborators.
For an over-grid image from a public URL, Apps Script also provides Sheet.insertImage(url, column, row). Google’s documentation requires the URL to be publicly accessible. If your API returns bytes only, use insertImage(blob, ...) instead. For blobs, Google documents a maximum supported size of 2 MB; compress, resize, or choose a smaller capture if an image is larger.
Or skip the browser setup
Use ScreenshotNeo when you want a browser-rendered capture without managing your own browser runtime. It accepts the URL and returns an image; the same request can be made from Apps Script with UrlFetchApp. The full option list is in the ScreenshotNeo API docs.
const apiKey = PropertiesService.getScriptProperties().getProperty('SCREENSHOTNEO_API_KEY');
const pageUrl = 'https://stripe.com';
const endpoint = 'https://api.screenshotneo.com/v1/shot?access_key=' +
encodeURIComponent(apiKey) + '&url=' + encodeURIComponent(pageUrl);
const response = UrlFetchApp.fetch(endpoint, { muteHttpExceptions: true });
if (response.getResponseCode() < 200 || response.getResponseCode() >= 300) {
throw new Error('Screenshot request failed: HTTP ' + response.getResponseCode());
}
const screenshotBlob = response.getBlob().setName('stripe.webp');
SpreadsheetApp.getActiveSheet().insertImage(screenshotBlob, 2, 2);
Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. Sign up for free ScreenshotNeo screenshots.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Authorization is required or external request permission error |
The script has not been authorized, or the manifest omits the external request scope. | Run the menu action or function from an authorized account and grant access. If scopes are explicit, include script.external_request and the needed spreadsheet scope. |
Sheet not found |
CONFIG.sheetName does not exactly match the tab name. |
Update the setting, save, then reload the spreadsheet. |
| Rows remain pending | Batch processing intentionally handles only a bounded number per run. | Choose Generate next batch again, or arrange a bounded trigger workflow. |
| Invalid URL status | Cell is blank, missing a scheme, or contains text rather than a complete URL. | Use an absolute http:// or https:// URL. Trim accidental whitespace. |
| HTTP error from the screenshot endpoint | Credentials, request parameters, account state, or remote service response may be invalid. | Check the key in Script Properties, confirm the encoded URL and current API documentation, then inspect the status and response body excerpt written to the row. |
| Expected image response but got another content type | The API returned an error document or a non-image result. | Inspect the response headers and service response. Do not insert an error page as an image. |
| Image exceeds 2 MB | Large full-page capture, high resolution, or image format/content produces a blob beyond Sheets’ documented limit. | Use a viewport capture, reduce dimensions, or use a hosted public image URL with IMAGE or URL insertion. |
| Image is missing for some collaborators | A URL-based image may require access they do not have, or the host may expire the link. | Use durable collaborator-accessible hosting or insert a blob into the sheet. Confirm access while signed into a collaborator account. |
| Capture is blank or looks different from the live page | Page is blocked, depends on delayed JavaScript, consent state, authentication, or content that loads after the initial render. | Use a browser screenshot service with appropriate wait, cookie, header, or authentication options. A basic HTTP fetch of HTML is not equivalent to browser rendering. |
| Script times out | Batch is too large, target pages are slow, or images take too long to fetch and insert. | Reduce batchSize, request smaller images, and resume in more executions. Observe your own execution times; there is no universal safe batch count. |
| Duplicate thumbnails after refresh | Existing over-grid images may be anchored differently or not match the configured column and row. | Remove old images manually or adjust the cleanup logic to match the image anchors used by your sheet. |
Performance, reliability, and cost
Performance
- Each row requires a remote screenshot request plus a spreadsheet image insertion. Page render duration and output size dominate; no fixed throughput should be assumed.
- Choose viewport captures when a full page is not needed. They are usually more manageable for insertion and easier to review in a grid.
- Use a bounded batch and measure in the destination script project. Apps Script’s six-minute execution ceiling means a slow run may need several batches.
UrlFetchApp.fetchAll()can issue multiple requests, but parallel requests do not eliminate execution-time, daily-quota, provider, or output-size constraints. For a first implementation, sequential processing makes per-row errors easier to associate. If you parallelize, preserve input-to-response mapping and handle each response independently.
Reliability
- Keep row-level statuses so partial success is visible and retryable.
- Do not treat a non-error HTTP status as proof that the target page rendered correctly; inspect the screenshot or use response metadata where available.
- For repeat captures, decide whether to overwrite existing images, create dated history, or skip successful rows. The example overwrites selected rows and skips successes in the next-batch action.
- Protect the key and ensure the image access model matches the spreadsheet audience.
Cost
Your workflow consumes Apps Script URL Fetch quota and may incur charges from the screenshot provider. Google quotas and provider prices can change; check current terms before deployment. A direct public image URL can avoid a screenshot request only when the source is already the exact image you need. It does not turn an ordinary webpage URL into a page capture.
Frequently asked questions
Can a Google Sheets formula take a screenshot of a webpage?
No. IMAGE displays an image URL. Use a browser-rendering screenshot service to create the image first.
Can I use a custom function to fill a neighboring cell with an image?
A custom function is a poor fit for side effects such as writing into another cell or inserting a floating image. Use a menu action or controlled trigger that has spreadsheet authorization.
Can the thumbnails update automatically?
Yes. A time-driven trigger can call a bounded processing function. Design it to skip completed rows, record failures, and stop when no pending work remains.
Can I make the thumbnail clickable?
Over-grid inserted images are not a reliable replacement for a cell hyperlink workflow. Keep the original URL in its own column, or use a formula/link layout that preserves a clickable destination alongside the preview.
Why not just fetch the page HTML and convert it?
HTML alone omits browser layout, stylesheets, script execution, and dynamic rendering. A true visual capture requires a browser-rendering step.


