Batch-Editing Contentful Entries at Scale
In CMS 1.0, one content type maps to hundreds of entries — so a single copy, SEO, or field change can mean editing every one by hand. I built a library of targeted scripts that apply the change across every matching entry — validated in Testing, then run in production.
The problem
The CMS 1.0 model maps one content type to many entries — often hundreds. A page per office, per location, per brand. That’s manageable until a change needs to land on all of them at once: a new meta-title pattern, a canonical domain fix, a hero tweak, a background color.
Doing that in the Contentful UI means an editor opening every entry, making the same edit, saving, and publishing — hundreds of times. It’s slow, mind-numbing, and exactly the kind of repetition where mistakes slip in unnoticed.
The structural fix for this sprawl was CMS 2.0, but the thousands of 1.0 entries that already exist still need bulk maintenance today.
What editors ask for
The requests are always some variation of “make this one change everywhere it applies.” Over time that turned into a set of content-type-specific scripts covering the fields that change most:
- Rewrite SEO title / description patterns across a brand’s office pages.
- Repoint or fix canonical URLs (e.g. swap a production domain for staging).
- Update hero fields, background colors, accordions, and multi-card content.
- Swap image assets or refresh blog detail pages.
- Bulk-create or delete entries when a content model shifts.
Approach
Every script follows the same shape, built on the Contentful Management API:
- Connect to a space and environment via the CMA client.
- Fetch a precise slice of entries with getEntries — a content type plus query filters that select exactly the entries that need changing.
- Mutate the localized field(s), then update() and publish() each entry.
- Process in small batches with a short pause between them to stay under CMA rate limits.
Everything is parameterized by environment, so the identical script can run against Testing or master just by changing one value.
1const contentfulManagement = require('contentful-management');2 3const { SPACE_ID, ACCESS_TOKEN } = process.env;4const ENVIRONMENT_ID = process.env.ENVIRONMENT_ID ?? 'Testing'; // Testing first, then master5const LANGUAGE = 'en-US';6const ENTRIES_PER_BATCH = 3;7 8const client = contentfulManagement.createClient({ accessToken: ACCESS_TOKEN });9 10async function run() {11 const space = await client.getSpace(SPACE_ID);12 const environment = await space.getEnvironment(ENVIRONMENT_ID);13 14 // Target only the entries that actually need the change — not the whole type.15 const { items } = await environment.getEntries({16 content_type: 'page',17 'sys.publishedAt[exists]': true,18 'fields.canonical[match]': 'www.clearchoice.com',19 limit: 500,20 });21 22 console.info(`Found ${items.length} entries to update`);23 24 for (let i = 0; i < items.length; i += ENTRIES_PER_BATCH) {25 const batch = items.slice(i, i + ENTRIES_PER_BATCH);26 27 await Promise.all(28 batch.map(async (entry) => {29 const canonical = entry.fields.canonical?.[LANGUAGE];30 if (!canonical?.includes('www.clearchoice.com')) return;31 32 entry.fields.canonical[LANGUAGE] = canonical.replace('www', 'wwwstg');33 34 const updated = await entry.update();35 await updated.publish();36 console.info(`Updated + published ${entry.sys.id}`);37 }),38 );39 40 // Breathe between batches to stay under CMA rate limits.41 await new Promise((r) => setTimeout(r, 3000));42 }43}44 45run().catch((err) => {46 console.error(err);47 process.exit(1);48});Targeting the right entries
The safety of a batch job lives in the query. Rather than fetch a whole content type and filter in code, each script uses Contentful’s search operators to pull back only the entries that should change — so a run can’t accidentally touch anything outside its intended scope.
1// Scope to one brand/app — only these entries are touched.2environment.getEntries({3 content_type: 'facilityDetailsPage',4 'fields.appName[in]': 'AspenDental - Office Details',5});6 7// Published pages whose canonical contains a given domain.8environment.getEntries({9 content_type: 'page',10 'sys.publishedAt[exists]': true,11 'fields.canonical[match]': 'clearchoice.com',12});13 14// Everything except the default/template entry.15environment.getEntries({16 content_type: 'facilityDetails',17 'fields.facilityId[ne]': 'Default',18});Testing first, always
No batch job goes straight to production. The workflow is deliberately boring, which is the point:
- Point
ENVIRONMENT_IDatTesting, run the script, and spot-check the results in the Testing space. - Once it’s validated, run the identical script against
master. - Small batches plus per-entry logging make it obvious what changed — and easy to stop early if something looks off.
1$ ENVIRONMENT_ID=Testing node updatePageCanonical.js2 3Found 42 entries to update4Updated + published 4Gg9k2mQ1a5Updated + published 7Kp0rT8s2b6Updated + published 1Zx4vB9n3c7...8All batches updated successfully.Editors never touch the scripts. They describe the change in plain language — “update the meta title on every Aspen Dental office page” — and we translate it into a targeted script.
We run it in Testing, share the result for sign-off, then run the same script in production. A change that would take an editor a full day of clicking lands in a few minutes, and they just reach out whenever the next one comes up.
Production impact
- Bulk edits land in minutes instead of days of manual clicking.
- Every matching entry gets the exact same change — no drift, no missed pages.
- Editors are unblocked: they request, we run.
- A Testing dry-run makes every production change low-risk and reviewable.
- A growing, reusable library covering the content types that change most.
Continue exploring
Engineering notes
Related case studies