Yoast Metadata Import Export
WordPress plugin for import/export of Yoast SEO metadata with client-side parsing, preview, and secure application of changes.
by Kavit Trivedi, Raam Dev · github.com/trivedikavit/yoast-metadata-import-export
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/trivedikavit/yoast-metadata-import-export/archive/refs/heads/trivedikavit%2Flocal.zipWordPress plugin to bulk export and import Yoast SEO meta for posts and terms as CSV. The Import tab parses CSV entirely in the browser, then applies changes server-side in small batches through a nonce-protected REST endpoint.
Features
- Export Yoast SEO meta for any selection of public post types and taxonomies to a single CSV file (UTF-8 with BOM, so spreadsheet apps detect the encoding correctly).
- Import that CSV back to update Yoast SEO meta in bulk, with three knobs:
- Dry run — validate without writing.
- Force — overwrite existing non-empty values.
- Clear existing — wipe every Yoast field on the target before writing.
- Batched imports with a live progress bar (see How it works for the request shape).
- React + TypeScript admin UI built on
@wordpress/componentsso it inherits the standard WordPress admin theme.
How it works (and why it's a fit for WPVIP)
The plugin never uploads your CSV as a file — parsing happens in the browser, and only small JSON batches cross the network. That's what makes it safe for very large imports and a natural fit for platforms with a read-only application filesystem like WPVIP.
Importing: the file never leaves your browser
- You drop a CSV on the upload zone in the Import tab.
- The browser reads the file into memory with
FileReader.readAsText()— no network request happens at this point. parseCsv()tokenises and validates rows locally in the browser.- The admin app slices the parsed rows into batches of 100 and POSTs each
batch as JSON to
/ymie/v1/import-batch, with a 1-second pause between requests. - PHP applies each batch with
update_post_meta()/update_term_meta()and returns a count.
Nothing else moves over the wire. There is no multipart/form-data upload,
no temp file in /tmp, no entry in wp-content/uploads/. The raw CSV exists
only inside the browser tab — PHP never sees it.
Exporting: the CSV is streamed, not stored
- The Export tab POSTs your post-type/taxonomy selection to
/ymie/v1/export. - The handler writes rows straight to
php://output(with a UTF-8 BOM), row by row. - The browser receives a
Content-Type: text/csvresponse and downloads it.
The server never writes the export to disk. There is no temp file, no upload to S3, no intermediate artifact — the HTTP response is the CSV.
Why this is great on read-only / locked-down platforms
WPVIP, Pantheon, WP Engine and Kinsta run application code on read-only filesystems (or ephemeral containers where any local write disappears at the next deploy). They also tend to enforce strict PHP limits:
| Constraint | Typical managed-host value | What it breaks for naive import plugins |
|---|---|---|
upload_max_filesize |
50–100 MB | Large CSVs are rejected before PHP ever runs |
post_max_size |
100–128 MB | Same, but for the POST body |
max_execution_time |
30–120 s | One long import request times out and leaves it half-done |
| Writable local disk | None (uploads bucket only) | move_uploaded_file(), tempnam(), fwrite to /tmp all fail |
Because this plugin never needs the local filesystem and never sends a large payload in a single request, none of those limits apply:
- No
upload_max_filesizeceiling. A 500 MB CSV with hundreds of thousands of rows is the browser's problem. The server only ever sees a small JSON blob containing 100 parsed rows — a few tens of KB per request. - No disk writes anywhere. Imports call
update_*_meta(); exports stream tophp://output. Nothing toucheswp-content/uploads/,/tmp, orsys_get_temp_dir(), so the read-only filesystem is never a problem. - No long-running request. Each batch (100 rows) finishes in well under a
second on typical hardware. A 100,000-row import is 1,000 short, independent
HTTP calls, so per-request execution time stays well under
max_execution_timeregardless of dataset size. The 1-second pause between batches also keeps the import from tripping WAF rate limits. - Resilient under failure. A failed batch is surfaced in the response and the next batch continues — you don't lose successful writes from earlier in the import when a later batch errors.
Requirements
- WordPress ≥ 6.0
- PHP ≥ 7.4
- Yoast SEO installed and active
- Node ≥ 18 and Composer 2.x for development
The plugin self-deactivates with an admin notice if Yoast SEO is not active.
Installation (development)
git clone <repo> yoast-metadata-import-export
cd yoast-metadata-import-export
composer install # PHPUnit + WordPress Coding Standards
npm install # React/TypeScript toolchain
npm run build # produces build/index.js and build/style-index.css
Symlink (or copy) the directory into your wp-content/plugins/ and activate
"Yoast Metadata Import Export" from the WordPress admin. The "Yoast Meta" menu
entry will appear in the sidebar.
Development scripts
JavaScript / TypeScript
npm run start # development build with watch
npm run build # production build
npm test # Jest unit tests (wp-scripts test-unit-js)
npm run lint # ESLint (src/)
npm run format # Prettier (src/)
npx tsc --noEmit # standalone TypeScript type-check
Build output goes to build/, which is committed — the plugin loads
compiled JS/CSS from there at runtime and must work on a host without a Node
toolchain. Re-run npm run build and commit the result whenever src/
changes. The PHP enqueue falls back to assets/css/admin.css only when the
build is missing.
PHP
composer install # install dev tooling (PHPUnit, PHPCS, WPCS)
vendor/bin/phpcs # WordPress coding standards check
vendor/bin/phpunit # unit tests (no WordPress install required)
The PHPUnit suite runs against tests/bootstrap.php, which stubs every
WordPress function the plugin touches in-memory — there is no need for a
wp-tests-lib environment.
Architecture
yoast-metadata-import-export.php Plugin entry: constants, hooks, Yoast dependency check
includes/
admin/class-ymie-admin.php Admin menu, asset enqueue, page render
api/class-ymie-rest.php REST routes (POST ymie/v1/export, ymie/v1/import-batch)
src/ TypeScript source (wp-scripts entry)
index.tsx React entry — mounts AdminApp on #ymie-root
admin-app.tsx Tabbed Export/Import UI
components/FileUpload.tsx Drag-and-drop CSV upload
csv-parser.ts Quote-aware CSV parser + row validator
api.ts apiFetch + raw-fetch wrappers around the REST routes
types.ts Shared TypeScript types + ymieSettings global
globals.d.ts CSS side-effect import declaration
style.css Admin app styles (bundled to build/style-index.css)
__tests__/csv-parser.test.ts Jest unit tests
assets/css/admin.css Fallback stylesheet used only when build/ is missing
build/ Compiled JS/CSS — committed; rebuild with `npm run build`
samples/sample.csv Example import file matching the export schema
tests/
bootstrap.php PHPUnit bootstrap with WP function/REST stubs
test-ymie.php Unit tests for YMIE_REST and YMIE_Admin wiring
REST API
Both routes live under the ymie/v1 namespace and require the
manage_options capability plus the standard REST nonce (X-WP-Nonce header,
created with action wp_rest).
POST /wp-json/ymie/v1/export
Streams a CSV download. Request body:
{
"postTypes": ["post", "page"],
"taxonomies": ["category", "post_tag"]
}
Response: Content-Type: text/csv; charset=utf-8 with Content-Disposition: attachment; filename="yoast-meta-export-YYYY-MM-DD.csv". The first four
columns are ID, Type, Type_Value, Title/Name; the remaining columns are the
23 Yoast meta keys listed in YMIE_REST::$yoast_fields.
POST /wp-json/ymie/v1/import-batch
Applies a batch of rows. Request body:
{
"batch": [
{ "id": 101, "type": "post", "_yoast_wpseo_title": "New title", ... }
],
"options": {
"dryRun": false,
"force": false,
"clearExisting": false
}
}
Response:
{
"success": true,
"updated": 1,
"errors": [],
"dry_run": false,
"options": { "dryRun": false, "force": false, "clearExisting": false }
}
Behaviour:
idmust be a positive integer;typemust be"post"or"term". Rows failing either check land inerrors.- Only keys that appear in
YMIE_REST::$yoast_fieldsare read from each row; extras are ignored. - Without
force, an existing non-empty Yoast value is preserved when the row would replace it (the row contributes nothing toupdated). clearExistingdeletes every Yoast field on the target before writes; unrelated post meta is untouched.- During
dryRun, noupdate_*_meta/delete_*_metacalls are made, but the row is still counted inupdatedso the client can preview the change set.
Supported Yoast fields
The 23 keys exported and imported by the plugin live in
YMIE_REST::$yoast_fields:
| Category | Keys |
|---|---|
| Core SEO | _yoast_wpseo_title, _yoast_wpseo_metadesc, _yoast_wpseo_focuskw, _yoast_wpseo_keywordsynonyms, _yoast_wpseo_focuskeywords, _yoast_wpseo_canonical, _yoast_wpseo_bctitle |
| Robots | _yoast_wpseo_meta-robots-noindex, _yoast_wpseo_meta-robots-nofollow, _yoast_wpseo_meta-robots-adv |
| Advanced | _yoast_wpseo_redirect, _yoast_wpseo_is_cornerstone, _yoast_wpseo_primary_category |
| Open Graph | _yoast_wpseo_opengraph-title, _yoast_wpseo_opengraph-description, _yoast_wpseo_opengraph-image, _yoast_wpseo_opengraph-image-id |
_yoast_wpseo_twitter-title, _yoast_wpseo_twitter-description, _yoast_wpseo_twitter-image, _yoast_wpseo_twitter-image-id |
|
| Schema | _yoast_wpseo_schema_page_type, _yoast_wpseo_schema_article_type |
A working CSV with the exact column order is in
samples/sample.csv.
Security
- Admin page and both REST routes require the
manage_optionscapability. - REST requests must carry a valid
wp_restnonce; the React client sends it viaapiFetch's nonce middleware and via the rawX-WP-Nonceheader on the export download. - File parsing happens entirely in the browser — the CSV body itself is never transmitted; only the parsed, validated row objects are POSTed.
- Every imported field passes through
sanitize_text_field()beforeupdate_post_meta()/update_term_meta().
License
GPL v2 or later.