Skip to main content
← Blog ··10 min read

Google Slides API: practical guide with real code examples

A practical Google Slides API guide: auth, batchUpdate, replaceAllText, dynamic slide insertion, and the pitfalls that bite at scale.

The Google Slides API is the most powerful and the most footgun-prone of the major presentation APIs. Powerful because everything in a deck — text, images, shapes, charts, layouts, masters — is addressable by stable object IDs and mutable through one transactional endpoint. Footgun-prone because that endpoint, presentations.batchUpdate, takes an array of forty different request types, each with its own field shape, and silently no-ops when you point one at the wrong target.

This piece is the version I’d hand a developer who has to ship a Slides-generation pipeline and doesn’t want to learn the lessons by losing a weekend. Real code, real pitfalls, in Node.js. For the broader product-side framing of why teams use the Slides API in the first place, the Google Slides automation covers the architectural view; here we go inside the API itself.

Auth: service account, OAuth, impersonation

The first decision is which auth posture the integration runs under, and it’s the one most tutorials skip past with a copy-pasted JSON key.

For a script that runs on a developer’s machine against their own decks, OAuth with the auth/presentations scope and a refresh token is fine. The googleapis library handles the token exchange; you store the refresh token in your secret manager and forget about it.

For anything that runs as a service — generating decks for users who aren’t the developer — you want a service account with domain-wide delegation, impersonating the user whose Drive the deck should land in. Without delegation the service account creates files in its own Drive, which is functionally a black hole no one in the org can see.

import { google } from 'googleapis';

async function getSlidesClient(userEmail) {
  const auth = new google.auth.JWT({
    email: process.env.GCP_SERVICE_ACCOUNT_EMAIL,
    key: process.env.GCP_SERVICE_ACCOUNT_KEY.replace(/\\n/g, '\n'),
    scopes: [
      'https://www.googleapis.com/auth/presentations',
      'https://www.googleapis.com/auth/drive',
    ],
    subject: userEmail, // impersonates this user
  });

  await auth.authorize();
  return google.slides({ version: 'v1', auth });
}

The subject field is the load-bearing line. Without it, the deck is owned by the service account and the user you’re generating for can’t see it. With it, the deck is owned by the user, the service account is invisible, and sharing behaves the way the rest of the Workspace expects.

Two pitfalls worth flagging. First, domain-wide delegation requires a Workspace admin to add the service account’s client ID to the allowed list with the exact scopes you’ll use — extra scopes added later won’t work until the admin re-authorises. Second, the JWT subject has to be a real user in the Workspace; impersonating an external Gmail account silently fails with a permission error that looks like a network issue.

batchUpdate is the only operation that matters

Once you’re authed, almost everything you do against the Slides API is a single endpoint: presentations.batchUpdate. It takes a presentation ID and an array of requests, each of which is one of about forty operation types — replaceAllText, insertText, createImage, duplicateObject, updateTextStyle, updatePageElementTransform, and so on.

The reason this matters: the API is designed for atomic mutation. A single batchUpdate with twenty requests is one round-trip and one transactional state change. If request fifteen fails, the API tells you which one and the deck is left in the pre-batch state. Twenty separate calls would be twenty failure modes and twenty half-mutated decks to clean up.

The practical consequence is that you build up the request array in memory, then ship it once.

async function fillTemplate(slides, presentationId, data) {
  const requests = [
    { replaceAllText: { containsText: { text: '{{client_name}}' }, replaceText: data.clientName } },
    { replaceAllText: { containsText: { text: '{{month}}' }, replaceText: data.month } },
    { replaceAllText: { containsText: { text: '{{revenue}}' }, replaceText: data.revenue } },
    { replaceAllText: { containsText: { text: '{{growth}}' }, replaceText: data.growthPct } },
  ];

  const response = await slides.presentations.batchUpdate({
    presentationId,
    requestBody: { requests },
  });

  return response.data.replies;
}

The response array is the same length as the request array, in order. Each entry contains whatever the operation returned — for replaceAllText it’s an occurrencesChanged count, which is the only signal you have that the placeholder existed. If occurrencesChanged is zero, the placeholder wasn’t in the deck and your generated output silently shipped with {{client_name}} in the title. Always read it.

replaceAllText, replaceAllShapesWithImage, replaceAllShapesWithSheetsChart

The three workhorse “find-and-replace” operations. Each does something different and each has a footgun.

replaceAllText swaps a literal text string everywhere it appears in the deck. Case-sensitive by default. The classic pattern is mustache-style placeholders — {{client_name}}, {{revenue}} — that no one would type by accident, so the find is safe.

The footgun: if your placeholder spans a formatting boundary (say someone bolded half of {{client_name}} while editing the template), the API treats it as two separate text runs and the find returns zero occurrences. The fix is brittle on the API side — you have to either reformat the template or use insertText with the placeholder as a target object, which is a different shape of code. Easier to enforce a “placeholders are always one formatting run” rule in the template and lint for it.

replaceAllShapesWithImage replaces every shape that contains a specific text marker with an image at a URL. This is how you swap brand logos, client headshots, generated charts.

{
  replaceAllShapesWithImage: {
    imageUrl: 'https://cdn.example.com/clients/acme/logo.png',
    containsText: { text: '{{logo}}' },
    replaceMethod: 'CENTER_INSIDE',
  }
}

The footgun: the image URL has to be publicly accessible to Google’s servers at the moment of the call. Signed URLs work if they’re valid for at least a couple of minutes. Localhost URLs and short-lived presigned URLs that expire mid-batch will fail the request. The image is also fetched server-side by Google and cached, so the first generation against a new image takes a beat longer than steady-state.

replaceAllShapesWithSheetsChart is the one that earns its keep on data-heavy decks. You build the chart once in a Google Sheet — bar, line, pie, whatever — and replace a marker shape in the deck with the rendered chart, linked back to the Sheet.

{
  replaceAllShapesWithSheetsChart: {
    spreadsheetId: '1AbC...XyZ',
    chartId: 1234567890,
    containsText: { text: '{{revenue_chart}}' },
    linkingMode: 'LINKED',
  }
}

The chart embeds as an image, but it’s a linked image — the user can refresh it from the slide later. The footgun: the chart has to exist in the Sheet and the chart ID has to be the numeric ID, not the embed URL. You get the ID from the Sheets API or by reading the chart object out of the spreadsheet. Hard-coding it works until someone deletes and recreates the chart in the Sheet, at which point the ID changes and your pipeline ships decks with empty rectangles.

Dynamic slide insertion via duplicateObject

The single hardest pattern in the Slides API: a deck where the number of slides depends on the data. Five clients this month, eight next month — same template, different slide count.

The right primitive is duplicateObject against a template slide, scoped to the page ID, then replaceAllText and replaceAllShapesWithImage scoped to the duplicated page ID’s children.

async function generatePerClientSlides(slides, presentationId, templateSlideId, clients) {
  // First batch: duplicate the template slide once per client.
  const duplicateRequests = clients.map((_, i) => ({
    duplicateObject: {
      objectId: templateSlideId,
      objectIds: { [templateSlideId]: `client_slide_${i}` },
    },
  }));

  const dupResponse = await slides.presentations.batchUpdate({
    presentationId,
    requestBody: { requests: duplicateRequests },
  });

  // Second batch: fill each duplicated slide with that client's data.
  // We have to scope replaceAllText with pageObjectIds so client A's
  // placeholders don't overwrite client B's.
  const fillRequests = clients.flatMap((client, i) => {
    const pageId = dupResponse.data.replies[i].duplicateObject.objectId;
    return [
      {
        replaceAllText: {
          containsText: { text: '{{client_name}}' },
          replaceText: client.name,
          pageObjectIds: [pageId],
        },
      },
      {
        replaceAllText: {
          containsText: { text: '{{client_revenue}}' },
          replaceText: client.revenue,
          pageObjectIds: [pageId],
        },
      },
    ];
  });

  // Third batch: delete the original template slide so it doesn't ship.
  fillRequests.push({ deleteObject: { objectId: templateSlideId } });

  await slides.presentations.batchUpdate({
    presentationId,
    requestBody: { requests: fillRequests },
  });
}

Three structural points that bite the first time you write this code.

First, the duplicate has to come before the fill in a separate batchUpdate call. You can’t both create a slide and reference its ID in the same batch — the IDs from the duplicate aren’t known until the response comes back.

Second, pageObjectIds is the load-bearing field on the fill batch. Without it, every replaceAllText walks the whole deck, and the last client’s data overwrites every other client’s slide. With it, the find is scoped to that one page.

Third, the original template slide. If you don’t delete it, it ships in the output with {{client_name}} still in the title. The cleanest pattern is to keep the template slide hidden in the source deck — Slides supports per-slide visibility — but the API doesn’t currently let you mutate skipped state cleanly, so most pipelines end up deleting the template slide as part of generation.

Pitfalls that bite at scale

The five things that separate a working dev-time prototype from a production pipeline.

Placeholders vs text frames. A “placeholder” in Slides is a special kind of text frame defined in the master/layout — title, subtitle, body. They behave differently from regular text frames. replaceAllText works on both, but insertText and updateTextStyle care about which one you’re targeting. If your template was built by importing a PowerPoint file, the placeholders may not be real placeholders — they’re just text frames that look like them. The symptom: text style mutations silently no-op.

Master vs layout vs slide. Slides has three levels of inheritance: the master, the layout, the individual slide. A logo on the master appears on every slide. A logo on a layout appears on every slide that uses that layout. Most templates put brand elements on the master; most generation code accidentally targets the slide. The result is duplicate logos or replacement that “doesn’t work” because the original is on the master, untouched.

Text formatting preservation. When you replaceAllText with text that’s longer than the placeholder, Slides preserves the formatting of the first character of the placeholder and applies it to the whole replacement. If the placeholder was bold and the replacement is forty words, the entire replacement is bold. The fix is to use insertText plus an explicit updateTextStyle with a fields mask, but it’s three operations instead of one and it’s the reason most production templates have a strict “placeholders are plain styled text” rule.

Font fallbacks. Google’s font rendering is centralised on their servers, but custom fonts uploaded to a Workspace render fine on the web and break on PDF export. The symptom: deck looks correct on slides.google.com, exports with Arial substituted in. The fix is to pre-flight every template against the export pipeline and avoid fonts that aren’t in Google’s standard set unless you’ve validated them on PDF specifically.

Rate limits. The Slides API has a per-minute and per-day quota that’s generous for prototyping and tight for pipelines. A backfill that regenerates a thousand decks will hit the limit; the right pattern is exponential backoff on 429 and a queue at the orchestration layer. The library implementations of retry usually handle the simple cases; the long-running batches need explicit pacing.

For sibling treatments at different layers of the stack: automate Google Slides Apps Script is the lighter-weight in-Workspace pattern, and python-pptx practical guide is the equivalent for the PowerPoint side.

When to stop writing this code yourself

The honest version: the Google Slides API is well-documented, the SDKs are stable, and a competent engineer can ship a working template-fill pipeline in a few days. The first template is the cheap one.

The cost lives in the long tail. The third template adds a conditional section the API doesn’t model cleanly. The fifth adds a chart type that doesn’t exist in Sheets. The eighth needs branded fonts that break on PDF export. The eleventh has a designer who wants to redesign the whole layout and can’t because the code is the source of truth, not the template.

The crossover point — when a template-driven platform stops being the lazy answer and becomes the right answer — is usually around the time the team stops calling it “the Slides script” and starts calling it “the deck generator.” The infrastructure has accreted; someone owns it; bug fixes happen on a schedule. That’s the moment the build-vs-buy maths flips.

For the operational layer above the API — when to use Slides vs PowerPoint, how to wire generation to your data, how to design templates that survive automation — see the Google Slides automation. For the in-Workspace alternative when an external API is overkill, automate Google Slides Apps Script covers the lower-power, higher-velocity option.

Common questions, answered

Do I need a Google Workspace account to use the Google Slides API? +
Not strictly — a personal Google account works for development. For production with shared templates, service account impersonation or domain-wide delegation in Workspace is what you actually want; otherwise the auth boundary becomes the bottleneck the moment more than one person touches the pipeline.
Why does the Slides API only have a batchUpdate method? +
Because slides are a graph of object IDs and the API is designed to make a sequence of mutations atomic. A single batchUpdate call with twenty operations is one network round-trip and one transactional state change. Twenty separate API calls would be twenty failure modes.
How do I insert a chart from a Google Sheet into a slide? +
Use replaceAllShapesWithSheetsChart, pointing at a chart that already exists in a Sheet. The API embeds the rendered chart as a linked image. The chart updates when the underlying Sheet does, but you have to call refreshSheetsChart explicitly — slides don't auto-refresh.
What's the right way to handle dynamic numbers of slides? +
Duplicate a template slide via duplicateObject for each row of data, then run replaceAllText scoped to the new slide's pageId. The pattern is template-as-mould: keep one canonical slide in the deck as the prototype and stamp copies of it driven by the data.
When should I stop writing direct Slides API code? +
When the maintenance cost of edge cases — placeholder vs text-frame, master vs layout, font fallbacks, image sizing, conditional sections — passes the cost of a template-driven platform. For most teams that's around the third or fourth report shape, or the first time a designer asks for a layout the API doesn't model cleanly.

Related reading

Stop hand-building the same document every cycle.

Tell us what you're trying to automate. We respond within one business day with a real number and a scoping call invitation.

Get Started →