Google Forms cannot export a PDF per response on its own. The working answer is a Google Apps Script bound to the Form, triggered on submit, that reads the new response, fills a template, and renders a PDF you email or save to Drive. There are two render paths: build a Google Doc and call getAs("application/pdf"), or call an HTML-to-PDF API like PDF4.dev with UrlFetchApp.fetch when you need exact CSS layout.
This guide shows the trigger setup, the response-to-variable mapping, both render paths, and how to deliver the file. Use it for registration confirmations, applications, certificates, and order forms.
Which approach should you use for Google Forms to PDF?
The right path depends on how much control you need over the layout and whether you want to write code. Here is the decision table.
| Approach | Layout control | Code? | Best for |
|---|---|---|---|
| Apps Script + Google Doc template | Low (Doc styling only) | Apps Script | Simple confirmations, internal records |
| Apps Script + HTML-to-PDF API | High (full CSS) | Apps Script | Certificates, branded receipts, invoices |
| No-code (n8n / Zapier / Make) | High (HTML node + API) | None | Teams without a developer |
| Manual export / add-on | Varies | None | One-off, low volume |
For most teams the split is clear. If the PDF only needs to restate the answers in a clean document, the Google Doc path is the fastest to ship. If the PDF is customer-facing and has to match a brand (fonts, colors, page breaks, a logo placed exactly), build it from HTML and call an HTML-to-PDF API. The no-code path fits teams without an Apps Script maintainer.
A FormResponse is the object Apps Script gives you for one submission. It exposes getItemResponses() (an ordered list of question/answer pairs) and getRespondentEmail() when the form collects emails. The spreadsheet trigger gives you e.namedValues instead, a plain object mapping each question title to an array of answers.
How do you set up the on-submit trigger in Apps Script?
Attach an installable onFormSubmit trigger so your function runs every time someone submits. The simple trigger (a function literally named onFormSubmit) cannot send email or call external URLs, so you must create an installable trigger, which runs with your authorization and full permissions.
Open the Form, click the three-dot menu, choose Apps Script. That gives you a script bound to the Form. Add the trigger in code once, or via the clock icon in the editor (Triggers panel).
// Run this function ONE time to install the trigger.
// Apps Script will ask for authorization the first time.
function installTrigger() {
const form = FormApp.getActiveForm();
ScriptApp.newTrigger("handleFormSubmit")
.forForm(form)
.onFormSubmit()
.create();
}The e.response field is present only on a form-bound trigger. If your script lives in the linked Google Sheet instead, read e.namedValues (see the next section). Install the trigger once, submit a test response, then check Executions in the editor to confirm handleFormSubmit ran without errors.
How do you map form answers to template variables?
Build a flat object that maps each template variable to one answer, because both render paths consume a key-value object. Question titles in Google Forms are not safe variable names (they contain spaces and punctuation), so translate them into clean keys you control.
Two sources give you the answers. From a form-bound trigger, iterate getItemResponses(). From a spreadsheet-bound trigger, read e.namedValues, where every value is an array (take index 0 for single answers).
function toTemplateData(response) {
const map = {};
response.getItemResponses().forEach(function (item) {
map[item.getItem().getTitle()] = item.getResponse();
});
// Map noisy question titles to clean variable names.
return {
full_name: map["Full name"] || "",
email: response.getRespondentEmail() || map["Email"] || "",
event: map["Which event are you registering for?"] || "",
seats: map["Number of seats"] || "1",
submitted_at: new Date().toLocaleString("en-US")
};
}Question titles are the join key, so renaming a question in the Form silently breaks the mapping. Keep titles stable, or read questions by their item ID with form.getItemById(id) if you expect the wording to change. A broken key returns an empty string, not an error, so test after every form edit.
How do you render a PDF from a Google Doc template?
The built-in path copies a Google Doc, replaces placeholder text with the answers, then exports the copy as PDF with getAs("application/pdf"). This needs no external service and works entirely inside Google's APIs. The tradeoff is layout: you get Google Docs styling, not arbitrary CSS.
Create a Doc that holds placeholders like {{full_name}} and {{event}}, note its file ID from the URL, then copy and fill it per submission.
const DOC_TEMPLATE_ID = "1AbCdEf_your_template_doc_id";
function renderPdfFromDoc(data) {
// 1. Copy the template so the original stays clean.
const copy = DriveApp.getFileById(DOC_TEMPLATE_ID).makeCopy(
"Confirmation - " + data.full_name
);
const doc = DocumentApp.openById(copy.getId());
const body = doc.getBody();
// 2. Replace each placeholder with the answer.
Object.keys(data).forEach(function (key) {
body.replaceText("{{" + key + "}}", String(data[key]));
});
doc.saveAndClose();
// 3. Export the filled copy as a PDF blob.
const pdf = DriveApp.getFileById(copy.getId()).getAs("application/pdf");
pdf.setName("Confirmation - " + data.full_name + ".pdf");
// 4. Optional: delete the temporary Doc copy to avoid clutter.
DriveApp.getFileById(copy.getId()).setTrashed(true);
return pdf; // a Blob ready to email or save
}This is enough for internal records and plain confirmations. Where it falls short: precise page sizes, web fonts, multi-column layouts, background colors that print, and exact spacing. Google Docs rounds and reflows in ways you cannot fully control. For anything a customer sees, the HTML path below gives pixel-level results.
How do you render a branded PDF with an HTML-to-PDF API?
Call an HTML-to-PDF API from Apps Script with UrlFetchApp.fetch to get full CSS control. PDF4.dev renders your HTML with headless Chromium server-side, so the PDF matches what a browser would print: real fonts, exact page size, CSS grid, page breaks, the lot. You send HTML (or a stored template ID) plus the form data, and get back a PDF.
Two ways to use it. Send raw HTML inline, or store a template once in PDF4.dev with {{variables}} and send only the data. Storing the template keeps your Apps Script short and lets non-developers edit the design.
const PDF4_KEY = "p4_live_xxx"; // store in Script Properties, not in code
function renderPdfFromHtml(data) {
const html =
"<html><head><style>" +
"body{font-family:Inter,sans-serif;padding:48px;color:#111827}" +
"h1{font-size:28px;margin:0 0 8px}" +
".muted{color:#6b7280}" +
"</style></head><body>" +
"<h1>Registration confirmed</h1>" +
"<p class='muted'>" + data.submitted_at + "</p>" +
"<p>Hi " + data.full_name + ", you are registered for <b>" +
data.event + "</b> (" + data.seats + " seat(s)).</p>" +
"</body></html>";
const res = UrlFetchApp.fetch("https://pdf4.dev/api/v1/render", {
method: "post",
contentType: "application/json",
headers: { Authorization: "Bearer " + PDF4_KEY },
payload: JSON.stringify({ html: html, data: {}, delivery: "base64" })
});
const body = JSON.parse(res.getContentText());
// base64 delivery returns { pdf_base64, size_bytes, duration_ms }
return Utilities.newBlob(
Utilities.base64Decode(body.pdf_base64),
"application/pdf",
"Confirmation - " + data.full_name + ".pdf"
);
}Store the API key in Project Settings, Script Properties, not inline, so it never sits in source you might share. Use delivery: "base64" inside Apps Script when you want the bytes directly to attach or save. Use delivery: "url" when you only need a link (large PDFs, or to pass the link onward without holding the file in memory).
Want to see the HTML render before wiring Apps Script? Paste your markup into the free Html To PdfTry it free tool to preview the exact PDF output, then move the same HTML into your UrlFetchApp call.
How do you email the PDF or save it to Drive?
Once you hold a PDF blob, send it with MailApp.sendEmail or store it with DriveApp.createFile. Both accept the blob returned by either render path, so the delivery code is identical whether you used the Doc or the HTML route. This is where the form's email field pays off: you reply straight to the submitter.
function deliver(pdfBlob, data) {
// 1. Email the PDF back to the submitter as an attachment.
if (data.email) {
MailApp.sendEmail({
to: data.email,
subject: "Your registration confirmation",
body:
"Hi " + data.full_name + ",\n\n" +
"Your confirmation for " + data.event + " is attached.\n\n" +
"Thanks!",
attachments: [pdfBlob]
});
}
// 2. Also archive a copy in a Drive folder.
const folder = DriveApp.getFoldersByName("Form confirmations").hasNext()
? DriveApp.getFoldersByName("Form confirmations").next()
: DriveApp.createFolder("Form confirmations");
folder.createFile(pdfBlob);
}Now wire it together. The handleFormSubmit function from the trigger section becomes the orchestrator: map, render, deliver.
function handleFormSubmit(e) {
const data = toTemplateData(e.response); // mapping step
const pdf = renderPdfFromTemplate(data); // or renderPdfFromDoc / renderPdfFromHtml
deliver(pdf, data); // email + archive
}Consumer Gmail accounts allow roughly 100 MailApp recipients per day and have a daily UrlFetchApp call cap; Google Workspace accounts get higher limits. Check Google's Apps Script quotas before pointing a high-traffic form at this. For hundreds of submissions a day, queue rows in a sheet and process them on a time-driven trigger instead of rendering inside onFormSubmit.
Can you do this without writing code?
Yes. A no-code automation platform watches for new Google Forms responses and renders the PDF through an HTTP node, with no Apps Script to maintain. The tradeoff is a monthly tool cost and slightly less flexibility than raw Apps Script, in exchange for a visual builder your whole team can edit.
The typical n8n flow has three nodes:
- Google Forms / Sheets trigger: fires on each new response and outputs the answers as JSON.
- HTTP Request node: POSTs to
https://pdf4.dev/api/v1/renderwith theAuthorization: Bearerheader and a body oftemplate_idplus the mappeddata, usingdelivery: "url". - Email or Drive node: attaches the returned PDF link or file and sends it.
Zapier and Make follow the same shape (trigger, then a "Webhooks" or "HTTP" action calling the render endpoint, then a delivery step). For the full walkthrough of wiring an HTTP node to the render API, see automating PDFs with Zapier and Make.
Which option should you choose?
Pick by what the PDF is for and who maintains the automation. Here is the recommendation by scenario.
| Scenario | Recommended path |
|---|---|
| Internal record of each submission | Apps Script + Google Doc template |
| Customer-facing receipt, certificate, or invoice | Apps Script + PDF4.dev (HTML/CSS) |
| Brand-exact design, frequent layout edits | Stored PDF4.dev template + Apps Script |
| No developer on the team | n8n / Zapier / Make + render API |
| One-off or very low volume | Manual Doc export |
- Need it shipped today, layout is not precious? Use the Google Doc path. It is self-contained and free.
- The PDF represents your brand? Build it from HTML and render with PDF4.dev. Headless Chromium gives you the same output a browser prints, with real fonts and exact page sizes a Google Doc cannot match.
- No one wants to own Apps Script? Use n8n, Zapier, or Make with the same render endpoint.
For adjacent workflows, the same UrlFetchApp plus render-API pattern turns spreadsheet rows into PDFs (see generate a PDF from Google Sheets) and database pages into documents (see generate a PDF from Notion). The Form is just one trigger; the render call stays the same.
Start with one question and one variable. Get a single submission to produce a PDF in your inbox, then add fields. The hardest part is not the rendering, it is keeping question titles and template variables in sync, so build the mapping incrementally and submit a test response after each change.
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.



