Skip to main content

Documentation Index

Fetch the complete documentation index at: https://formsplugin.com/docs/llms.txt

Use this file to discover all available pages before exploring further.

Save Form Uploads to Your Own Google Drive

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.

Step 1 - Create a Google Drive Folder

This folder will become the home for every file uploaded through your forms.
  1. Go to drive.google.com and sign in with your Google account
  2. Right-click any empty area, choose New folder, name it something like FormsPlugin Uploads, then click Create
  3. Open the folder and copy the full URL from your browser’s address bar
The URL will look like this:
https://drive.google.com/drive/folders/1aBcDeFgHiJkLmN_EXAMPLE
Copy the entire URL from the address bar as it is. You don’t need to extract any ID, the script will handle that automatically.

Step 2 - Create the Apps Script

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.

2.1 - Open Google Apps Script

Go to script.google.com and click New project.
Google Apps Script homepage with the New project button highlighted in the top-left corner

2.2 - Paste the Script

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);
}

2.3 - Edit the Three Config Lines

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.
var ABSOLUTE_MAX_SIZE_MB = 25;

2.4 - Save the Project

Press Ctrl + S (or Cmd + S on Mac). Give the project a name like FormsPlugin Upload and click Rename.

2.5 - Deploy as a Web App

Now you’ll publish the script so your form can talk to it.
  1. Click Deploy at the top-right, then New deployment
Apps Script editor with the Deploy dropdown menu open, showing New deployment, Manage deployments, and Test deployments options
  1. Click the gear icon next to Select type and choose Web app
  2. Fill in the form:
    • Description: FormsPlugin Upload v1 (anything you like)
    • Execute as: Me (your Google account)
    • Who has access: Anyone
  3. Click Deploy
New deployment modal with Web app selected, Description filled in, Execute as set to Me, and Who has access set to Anyone
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.

2.6 - Copy the Web App URL

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.
Deployment success modal showing the Web app URL with a Copy button highlighted below it
Important - Every time you edit the script later, you must create a New deployment (not “Manage deployments”). Otherwise your changes won’t take effect.

Step 3 - Connect Forms Plugin to Your Drive

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.

3.1 - Switch Storage to Google Drive

Find the Storage option and change it to Google Drive. New fields will appear below it.

3.2 - Paste the Apps Script URL

In the Apps Script URL field, paste the /exec URL you copied at the end of Step 2. This controls what kind of link your form submission will contain after a file is uploaded.
  • Downloadable - Returns a direct file URL. Clicking it downloads the file straight to the visitor’s device.
  • Preview only - Returns a Google Drive preview page URL. Visitors view the file inside Drive without downloading.
Pick whichever fits your workflow.
Forms Plugin Upload Settings panel showing Storage set to Google Drive, the Apps Script URL field with a pasted URL, and Link Type set to Preview only

3.4 - (Optional) Customize the Folder Structure

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.
Upload Settings with Customize Folder turned On, revealing Form Name override, Date/Time Folder, and Folder by Field controls
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.
OptionFolder name example
Off(no date folder created)
Date2026-05-18
Date + Time2026-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.
JoinerResult for fields fullName + email
SpaceJohn Doe john@gmail.com
DashJohn Doe-john@gmail.com
SlashCreates nested subfolders, one per value
UnderscoreJohn Doe_john@gmail.com
CustomUse any character you type in the Custom Joiner field

3.5 - Example Folder Path

With all customization options on, a file uploaded to a contact form might land here:
FormsPlugin Uploads/contact-form/2026-05-18/John Doe john@gmail.com/resume.pdf
Empty levels collapse automatically, so if a visitor leaves their email blank, the field-combo level is skipped without leaving an awkward separator.

Re-Deploying After Edits

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.
  1. In the Apps Script editor, click Deploy then New deployment
  2. Choose Web app again
  3. 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.

How Your Files Stay Safe

  • 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

Things to Keep in Mind

  • 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.

Next Steps