By default, files uploaded through Forms Plugin upload fields are stored on our hosted storage. If you’d rather keep every uploaded file inside your own Google Drive, this guide walks you through the one-time setup.It applies to all six upload-type fields:
File Upload
Image Upload
Audio Upload
Video Upload
Voice Upload
Signature
What you’ll need - A Google account. Setup takes about 10 minutes and only needs to be done once. After that, every file submitted through any of these fields lands directly in your Drive folder, organized into clean subfolders.
The Apps Script is a small piece of code that runs under your own Google account. It receives files from your form and saves them to the Drive folder you just created.
Select all the existing code in the editor and delete it. Then paste the script below.
The script is provided by Forms Plugin and is safe to use. It only runs on your own Google account and writes files to the folder you choose.
Google Apps Script
/** * ═══════════════════════════════════════════════════════════════════════════ * * FormsPlugin · Google Drive Upload Endpoint * * Ships uploaded files from FormsPlugin's upload field-types into your own * Google Drive folder. No third-party server in the middle — your Drive, * your script, your account, your storage quota. * * Designed for use with the FormsPlugin Framer code components: * * FileUploadControl ImageUploadControl * AudioUploadControl VideoUploadControl * VoiceUploadControl SignaturePadControl * * Pair this script with the "Storage = Google Drive" option in the * FormsPlugin property panel. * * ─────────────────────────────────────────────────────────────────────────── * Product FormsPlugin · https://formsplugin.com * Vendor FramerGeeks · https://framergeeks.com * Docs https://docs.formsplugin.com/integrations/google-drive * Support team@formsplugin.com * License MIT — Copyright (c) FramerGeeks. Bundled with FormsPlugin. * Version 1.0.0 (2026-05-18) * ─────────────────────────────────────────────────────────────────────────── * * ▌ HOW IT WORKS * * 1. The FormsPlugin upload control on your Framer site captures the file. * 2. The control POSTs JSON (base64-encoded file + folder metadata + origin) * to YOUR deployed copy of this script. * 3. This script — running under YOUR Google account — validates the * request, writes the file into the configured Drive folder, and * returns a shareable URL. * 4. The FormsPlugin form submission includes that URL as the field value. * * Your file path: * <ROOT_FOLDER>/<form-name>/<date-time>/<combined-fields>/<filename> * * Level order is date-first so submissions naturally group by day — * easy to answer "how many uploads today?" by browsing one folder. * The 3 inner levels are toggleable per upload component in the Framer * property panel (Storage → Customize Folder). * * * ▌ ONE-TIME SETUP (5 minutes) * * ┌─ STEP 1 Create a Drive folder ─────────────────────────────────────┐ * │ · drive.google.com → New → New folder │ * │ · Name it whatever you like (e.g. "FormsPlugin Uploads") │ * │ · Open the folder, copy the URL from the address bar │ * └─────────────────────────────────────────────────────────────────────┘ * * ┌─ STEP 2 Create an Apps Script project ─────────────────────────────┐ * │ · script.google.com → New project │ * │ · Delete the placeholder `function myFunction() {}` │ * │ · Paste THIS entire file into the editor │ * │ · Rename project (top-left): "FormsPlugin Upload" │ * └─────────────────────────────────────────────────────────────────────┘ * * ┌─ STEP 3 Edit the CONFIG block below ───────────────────────────────┐ * │ · ROOT_FOLDER_URL — paste the URL from Step 1 │ * │ · ALLOWED_ORIGINS — list every Framer site URL allowed to │ * │ upload (empty array = allow any origin — │ * │ OK for testing, NOT for production) │ * │ · ABSOLUTE_MAX_SIZE_MB — hard server-side cap (default 25) │ * │ · Cmd/Ctrl + S to save │ * └─────────────────────────────────────────────────────────────────────┘ * * ┌─ STEP 4 Deploy as Web App ─────────────────────────────────────────┐ * │ · Top-right: Deploy → New deployment │ * │ · Click the gear ⚙ → Type: Web app │ * │ · Description: FormsPlugin Upload v1 │ * │ · Execute as: Me (your Google account) │ * │ · Who has access: Anyone │ * │ · Click Deploy │ * │ · Authorize (Advanced → Go to project → Allow) │ * │ · Copy the Web app URL ending in /exec │ * └─────────────────────────────────────────────────────────────────────┘ * * ┌─ STEP 5 Wire it into FormsPlugin ──────────────────────────────────┐ * │ In Framer canvas, select any FormsPlugin upload field, then in │ * │ the right panel under Upload Settings (or Storage for Voice / │ * │ Signature): │ * │ Storage = Google Drive │ * │ Apps Script URL = <paste the /exec URL> │ * │ Link Type = Downloadable (or Preview only) │ * │ Optional (Customize Folder = On): │ * │ Form Name (override) = leave empty for auto-detect │ * │ Folder by Field = e.g. "email" │ * │ Date/Time Folder = Off / Date / Date + Time │ * └─────────────────────────────────────────────────────────────────────┘ * * * ▌ HEALTH CHECK * * After deploying, open the /exec URL in a browser tab. You should see: * {"status":"ok","service":"FormsPlugin Google Drive Upload","version":"1.0.0"} * If you see "Page not found" or an auth wall, redeploy with * Who has access: Anyone. * * * ▌ RE-DEPLOY after editing this script * * Apps Script serves the deployed snapshot — saving the editor alone is * NOT enough. To publish edits: * Deploy → Manage deployments → pencil ✏ icon on the active row * → Version: New version → Deploy * The /exec URL stays the same. No changes needed in Framer. * * * ▌ SECURITY MODEL * * · The /exec URL is public-by-design. Anyone with the URL can attempt to * POST. The defenses below stop abuse: * * (a) Origin allowlist → ALLOWED_ORIGINS * (b) Server-side size cap → ABSOLUTE_MAX_SIZE_MB * (c) Folder + filename sanitization → strips / \ : * ? " < > | * (d) Google Apps Script quotas → built-in rate limits * * · Your Drive credentials NEVER reach the browser — the script runs as * YOU on Google's servers; the browser only sees the public /exec URL. * * · For production, ALWAYS set ALLOWED_ORIGINS to your real site domains. * * * ▌ CHANGELOG * * 1.0.0 2026-05-18 Initial release with nested-folder routing, * per-request maxSizeMB, origin allowlist, * sanitization, GET health check. * * ═══════════════════════════════════════════════════════════════════════════ */// ╔═════════════════════════════════════════════════════════════════════════╗// ║ CONFIG — edit these 3 lines ║// ╚═════════════════════════════════════════════════════════════════════════╝/** * The URL of the Google Drive folder where uploads will be saved. * Files are written into per-form subfolders inside this root. */var ROOT_FOLDER_URL = 'https://drive.google.com/drive/folders/REPLACE_WITH_YOUR_FOLDER_URL';/** * List of HTTP origins permitted to upload (no trailing slash). * Include every Framer site URL + every custom domain you've mapped. * Leave as [] to allow ANY origin — useful for first-time testing, * NEVER recommended for production. * * Example: * var ALLOWED_ORIGINS = [ * 'https://myforms.framer.website', * 'https://myforms.com' * ]; */var ALLOWED_ORIGINS = [ 'https://your-site.framer.website', 'https://your-custom-domain.com'];/** * Hard server-side ceiling on file size, in megabytes. * The FormsPlugin component sends its own per-field "Max Size" with each * upload. The effective cap is min(component_value, ABSOLUTE_MAX_SIZE_MB), * so a tampered or missing client value can never exceed this number. * Apps Script itself has a ~50 MB body limit (base64 ≈ 37 MB raw). */var ABSOLUTE_MAX_SIZE_MB = 25;// ╔═════════════════════════════════════════════════════════════════════════╗// ║ Below this line is FormsPlugin internals — no edits needed ║// ╚═════════════════════════════════════════════════════════════════════════╝var FORMSPLUGIN_VERSION = '1.0.0';/** * Upload endpoint — called by the FormsPlugin component on every successful * client-side file selection. * * Request body (JSON, sent as text/plain to skip CORS preflight): * { * filename: string, * mimeType: string, * size: number, // bytes * fileData: string, // base64, no data: prefix * linkType: 'download' | 'preview', * formName: string, // sanitized to folder-safe * dynamicFolder: string, // empty = skip this level * dateFolder: string, // empty = skip this level * maxSizeMB: number, // component's per-field cap * origin: string // window.location.origin * } * * Response (JSON): * { url, id, folderPath } // on success * { error } // on rejection — surfaced verbatim * to the user in the component UI */function doPost(e) { try { if (!e || !e.postData || !e.postData.contents) { return jsonOut({ error: 'Empty request body' }); } var body; try { body = JSON.parse(e.postData.contents); } catch (parseErr) { return jsonOut({ error: 'Invalid JSON body' }); } // Origin allowlist — reject calls from any site not on the list. if (ALLOWED_ORIGINS.length > 0) { if (!body.origin || ALLOWED_ORIGINS.indexOf(body.origin) === -1) { return jsonOut({ error: 'Origin not allowed' }); } } // Required fields if (!body.filename || !body.fileData) { return jsonOut({ error: 'Missing filename or fileData' }); } // Size cap. Component sends its per-field maxSizeMB; we floor it at // ABSOLUTE_MAX_SIZE_MB so a tampered/missing value cannot exceed the // script-side ceiling. var clientCap = Number(body.maxSizeMB); if (!isFinite(clientCap) || clientCap <= 0) clientCap = ABSOLUTE_MAX_SIZE_MB; var effectiveCap = Math.min(clientCap, ABSOLUTE_MAX_SIZE_MB); var sizeMB = (body.size || 0) / 1024 / 1024; if (sizeMB > effectiveCap) { return jsonOut({ error: 'File too large. Max ' + effectiveCap + 'MB.' }); } // Resolve the configured root folder. var rootFolder; try { rootFolder = DriveApp.getFolderById(extractFolderId(ROOT_FOLDER_URL)); } catch (folderErr) { return jsonOut({ error: 'Root folder not found. Check ROOT_FOLDER_URL.' }); } // Walk / create nested subfolders. Level order is date-first so // submissions cluster by day for easy archive browsing: // 1. formName — always (defaults to "default") // 2. dateFolder — date or date+time stamp (optional) // 3. dynamicFolder — values of one or more form fields joined by // the component's chosen separator. When the // separator is "/", the component sends a path // like "John Doe/john@gmail.com" and we split // on "/" so each piece becomes its own nested // subfolder (each sanitized individually). // Empty levels / pieces collapse — only non-empty pieces nest. var dynamicPieces = String(body.dynamicFolder || '') .split('/') .map(sanitizeFolderName) .filter(function (p) { return p && p !== ''; }); var levels = [sanitizeFolderName(body.formName || 'default'), sanitizeFolderName(body.dateFolder || '')] .filter(function (lvl) { return lvl && lvl !== ''; }) .concat(dynamicPieces); var targetFolder = rootFolder; for (var i = 0; i < levels.length; i++) { targetFolder = getOrCreateChildFolder(targetFolder, levels[i]); } // Build the file from the base64 payload. var safeFilename = sanitizeFilename(body.filename); var decodedBytes; try { decodedBytes = Utilities.base64Decode(body.fileData); } catch (decodeErr) { return jsonOut({ error: 'Invalid fileData (base64 decode failed)' }); } // Defense-in-depth: re-check the ACTUAL decoded byte length against // the cap. body.size is client-reported and could be lower than the // real payload; this catches any tampered or malformed request that // slipped past the earlier size check. var actualMB = decodedBytes.length / 1024 / 1024; if (actualMB > effectiveCap) { return jsonOut({ error: 'File too large. Max ' + effectiveCap + 'MB.' }); } var blob = Utilities.newBlob( decodedBytes, body.mimeType || 'application/octet-stream', safeFilename ); var file = targetFolder.createFile(blob); file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); var url = body.linkType === 'preview' ? 'https://drive.google.com/file/d/' + file.getId() + '/preview' : file.getDownloadUrl(); return jsonOut({ url: url, id: file.getId(), folderPath: levels.join('/') }); } catch (err) { return jsonOut({ error: 'Server error: ' + (err && err.message ? err.message : String(err)) }); }}/** * Health-check endpoint — open the /exec URL in a browser to confirm * the deployment is live and accessible. */function doGet() { return jsonOut({ status: 'ok', service: 'FormsPlugin Google Drive Upload', version: FORMSPLUGIN_VERSION });}// ─── Utilities ──────────────────────────────────────────────────────────────/** * Pull the folder ID out of a Drive folder URL. * Accepts either the full URL or a bare ID. */function extractFolderId(url) { var m = String(url || '').match(/folders\/([a-zA-Z0-9_-]+)/); return m ? m[1] : url;}/** * Return an existing child folder by name, or create one if missing. */function getOrCreateChildFolder(parent, name) { var iter = parent.getFoldersByName(name); if (iter.hasNext()) return iter.next(); return parent.createFolder(name);}/** * Folder-name sanitizer. * Returns "" for empty/falsy input — callers filter empty levels out so the * level is skipped entirely (no "default" fallback at non-root levels). * Strips path separators and Windows-reserved characters, trims to 80 chars. */function sanitizeFolderName(name) { if (!name) return ''; return String(name).replace(/[\/\\:*?"<>|]+/g, '_').trim().slice(0, 80);}/** * Filename sanitizer. Strips path separators and Windows-reserved characters, * preserves dots (extension), caps length at 200, falls back to a timestamped * name when the input collapses to empty. */function sanitizeFilename(name) { var clean = String(name).replace(/[\/\\:*?"<>|]+/g, '_').trim().slice(0, 200); return clean || ('upload_' + Date.now());}/** * Wrap a JS object in a JSON ContentService response. */function jsonOut(obj) { return ContentService .createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON);}
At the top of the script you’ll see a CONFIG block with three values. Update each one as described below.ROOT_FOLDER_URL - Replace the placeholder URL with the Google Drive folder URL you copied in Step 1.
var ROOT_FOLDER_URL = 'https://drive.google.com/drive/folders/1aBcDeFgHiJkLmN_EXAMPLE';
ALLOWED_ORIGINS - This is your safety net. Only the websites listed here are allowed to upload files to your Drive. List every domain where your form is published, including the Framer subdomain and any custom domain.
var ALLOWED_ORIGINS = [ 'https://your-site.framer.website', 'https://your-custom-domain.com'];
If you leave the list empty ([]), any website on the internet can upload files to your Drive folder. This is fine for first-time testing, but always add your real domains before going live.
ABSOLUTE_MAX_SIZE_MB - The largest file size (in MB) your script will accept. The default value of 25 is a safe choice.
Now you’ll publish the script so your form can talk to it.
Click Deploy at the top-right, then New deployment
Click the gear icon next to Select type and choose Web app
Fill in the form:
Description: FormsPlugin Upload v1 (anything you like)
Execute as: Me (your Google account)
Who has access: Anyone
Click Deploy
A permissions popup will appear. Click Authorize access, choose your Google account, and click Allow.
You may see a warning that says “This app isn’t verified.” This is normal because the script is your own private deployment. Click Advanced, then Go to [your project] (unsafe), and finally Allow to give the script permission to save files to your Drive.
After deploying, Google will show you a Web app URL that looks like this:
https://script.google.com/macros/s/AKfycb.../exec
Click Copy. You’ll paste this URL into Forms Plugin in the next step.
Important - Every time you edit the script later, you must create a New deployment (not “Manage deployments”). Otherwise your changes won’t take effect.
Now switch back to Framer. Select any upload field on your canvas (File Upload, Image Upload, Audio Upload, Video Upload, Voice Upload, or Signature) and open the Upload Settings panel on the right.
By default, every file lands inside your root folder under a subfolder named after the form. If you want more control, toggle Customize Folder to On. The following extra settings will appear.
Form Name (override) - Leave empty to let the field auto-detect the parent form’s name. Set a custom value here if you want a specific folder name instead.Date/Time Folder - Adds a date or date + time subfolder so submissions group naturally.
Option
Folder name example
Off
(no date folder created)
Date
2026-05-18
Date + Time
2026-05-18_14-32-08
Folder by Field - Click the { } token picker and select one or more form fields from your form (like Full Name or Email). The live values entered by the visitor at submission time are joined together and used as the next folder level. Empty values are skipped automatically.Joiner - If you list more than one field above, this controls the character placed between their values.
Joiner
Result for fields fullName + email
Space
John Doe john@gmail.com
Dash
John Doe-john@gmail.com
Slash
Creates nested subfolders, one per value
Underscore
John Doe_john@gmail.com
Custom
Use any character you type in the Custom Joiner field
If you ever edit the script (for example to add a new domain to ALLOWED_ORIGINS), saving the editor alone is not enough. You must create a fresh deployment for the changes to go live.
In the Apps Script editor, click Deploy then New deployment
Choose Web app again
Click Deploy and Done
Your /exec URL stays the same, so no changes are needed in Framer.
Do not use Manage deployments to update the existing one. Always create a New deployment so your edits go live.
The /exec URL is public by design, but the Allowed Origins list blocks any website that isn’t on your approved list
The script always enforces the Absolute Max Size even if a tampered request claims something different
File names and folder names are automatically cleaned of special characters that could break the folder structure
Your Google account credentials never leave Google, the script runs as you on Google’s servers and the visitor’s browser only ever sees the public /exec URL
The maximum file size per upload is 25 MB. This limit is enforced both on the Forms Plugin component and inside the script.
Google Drive’s daily upload and bandwidth quotas apply, the same limits as your regular Drive usage.
Files are saved with Anyone with the link can view sharing so the URL embedded in your submission email is openable. Change this on individual files in Drive if needed.