Export WordPress posts to Google Sheets via the REST API, including the permalink setting that 404s every route and the rendered objects your titles arrive inside.
WordPress's built-in export produces an XML file designed for migrating between sites, not a table you can analyse, so getting content into a spreadsheet means the REST API. Two things account for most failed first attempts: the API returns 404 on every route when permalinks are set to Plain, and titles and content arrive as objects rather than strings.
WordPress to Google Sheets for free (Apps Script + REST API)
First, check Settings → Permalinks is not set to Plain. If it is, choose Post name and save, which flushes the rewrite rules that create the /wp-json/ routes. Public posts can be read without any authentication at all, so if you only need published content you can skip credentials entirely.
For drafts, private posts or any custom field, generate an Application Password from Users → Profile → Application Passwords and use it with basic auth. This is built into WordPress and can be revoked without changing your real password.
function importWordPressPosts() { const SITE = "https://yoursite.com"; const USER = "your-username"; const APP_PASSWORD = "xxxx xxxx xxxx xxxx xxxx xxxx"; // Application Password const headers = { Authorization: "Basic " + Utilities.base64Encode(USER + ":" + APP_PASSWORD) }; const rows = []; let page = 1; while (true) { const url = SITE + "/wp-json/wp/v2/posts" + "?per_page=100&page=" + page // 100 is the maximum + "&status=any" // defaults to publish only + "&_embed=author,wp:term"; // inline author and term names const response = UrlFetchApp.fetch(url, { headers, muteHttpExceptions: true }); // Asking past the last page returns 400 rather than an empty array. if (response.getResponseCode() === 400) break; if (response.getResponseCode() !== 200) { throw new Error(response.getResponseCode() + ": " + response.getContentText()); } const posts = JSON.parse(response.getContentText()); if (!posts.length) break; posts.forEach((p) => { const embedded = p._embedded || {}; const author = (embedded.author || [{}])[0].name || p.author; // wp:term is an array of arrays: categories, then tags. const terms = (embedded["wp:term"] || []); const categories = (terms[0] || []).map((t) => t.name).join(", "); const tags = (terms[1] || []).map((t) => t.name).join(", "); rows.push([ p.id, stripHtml(p.title.rendered), // objects, not strings p.status, author, categories, tags, p.date, p.modified, stripHtml(p.excerpt.rendered).slice(0, 300), p.link, ]); }); if (posts.length < 100) break; page++; } const header = ["ID","Title","Status","Author","Categories","Tags", "Published","Modified","Excerpt","URL"]; const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = ss.getSheetByName("Posts") || ss.insertSheet("Posts"); sheet.clearContents(); sheet.getRange(1, 1, 1, header.length).setValues([header]); if (rows.length) { sheet.getRange(2, 1, rows.length, header.length).setValues(rows); }} // Rendered values contain HTML tags and entities.function stripHtml(html) { return String(html || "") .replace(/<[^>]+>/g, "") .replace(/&/g, "&").replace(/’/g, "'") .replace(/“|”/g, '"').replace(/ /g, " ") .trim();}Two behaviours worth knowing. Requesting a page beyond the last one returns HTTP 400 rather than an empty array, so a loop that only checks for an empty result will throw. And the _embedded["wp:term"] structure is an array of arrays, ordered by the taxonomies attached to the post type, with categories first and tags second on standard posts.
Limits: Apps Script's six-minute cap, plus each page is a live database query against the site, so large blogs on modest hosting get slow. Custom fields are only exposed when registered with show_in_rest, so ACF fields often need the plugin's REST setting enabling first.
The Brooked method
Brooked unwraps the rendered objects, strips HTML entities, resolves author and term IDs to names, and handles the end-of-pagination behaviour, so posts arrive as clean text columns. Posts, pages, custom post types, users and comments can all import into one spreadsheet on a single schedule.
Step 1: Install Brooked
Install Brooked from the Google Workspace Marketplace. Free tier includes 100 imports per month, no credit card required.
Step 2: Connect WordPress
Choose WordPress in Brooked, enter your site URL, username and an Application Password. Confirm permalinks are not set to Plain first, since that single setting disables every REST route.
Step 3: Schedule the refresh
Set the import to re-run daily or weekly so a content audit stays current, picking up newly published posts and updated modification dates without anyone re-running an export.
Method comparison
| Method | Cost | Setup | Refresh | Two-way | Best for |
|---|---|---|---|---|---|
| WordPress export tool | Free | 2 min | Manual | No | Full-site backup, though it outputs XML not CSV |
| Apps Script + REST API | Free | 20 to 35 min | Time-driven trigger | DIY | Content audits and custom post types |
| Export plugins | Free tier · paid for scheduling | 10 to 20 min | Scheduled (paid tiers) | No | Site owners who prefer plugins to code |
| Brooked | Free tier · Pro $29/user/mo | ~3 min | Scheduled (hourly, daily, weekly) | Yes | Scheduled content reporting in Sheets |
Common use cases
- Content audits: every post with author, category, publish and modified dates in one table
- Refresh planning: find posts untouched longest and prioritise updates
- Editorial reporting: output per author or category over time
- SEO joins: combine post metadata with Search Console data in the same sheet
- Migration prep: inventory posts, pages and custom post types before a replatform
Troubleshooting common issues
Related Articles
- How to Connect Pipedrive to Google Sheets
- How to Connect Monday.com to Google Sheets
- Connect Google Search Console to Google Sheets
Frequently asked questions
Why does /wp-json/ return 404 on my WordPress site?
The REST API routes only exist when WordPress uses pretty permalinks. With Settings → Permalinks set to Plain, every /wp-json/ URL 404s. Switch to Post name and re-save the permalinks page to flush the rewrite rules. If it still fails, a security plugin, a host-level rule, or a snippet disabling the REST API for unauthenticated users is likely responsible.
Why is my post title an object instead of a string?
WordPress returns title, content and excerpt as objects with a rendered property containing the HTML, because the raw and rendered forms can differ. Read title.rendered rather than title. The rendered value also contains HTML entities and tags, so strip or decode it before writing to a cell.
How do I authenticate with the WordPress REST API?
Application Passwords, built into WordPress since 5.6. Generate one from Users → Profile → Application Passwords, then use basic auth with your username and that password. It requires HTTPS, and it can be revoked independently of your real password, which makes it far safer than putting your login in a script.
Why do I only see published posts?
The status parameter defaults to publish. Drafts, pending and private posts require status=draft, status=any or similar, and crucially they require authentication: an unauthenticated request asking for drafts returns an error rather than the posts, since unpublished content is not public.
Why do author and category columns show numbers?
Posts reference authors and terms by ID. Either add _embed to the request, which inlines author and term objects under _embedded, or fetch the users and categories endpoints separately and build ID-to-name maps. _embed is simpler but makes responses considerably larger.
Why is my custom post type missing from the API?
Custom post types are only exposed when registered with show_in_rest set to true, and they appear under their own route rather than /wp/v2/posts. Many older themes and plugins register types without that flag, in which case the fix is in the registration code, not the request.
Connect WordPress to Google Sheets today.
Free tier: 100 imports per month, no credit card required. Or see the full WordPress integration details.
Install Brooked free →

