npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@qkix/strapi-plugin-ferry

v0.2.0

Published

Import and export Strapi v5 content as JSON or CSV - relations, components and dynamic zones included, with a dry run before anything is written

Readme

Why another one of these

Import and export is the most-wanted thing in the Strapi ecosystem that nobody maintains. The original plugin was last published in December 2023 and requires Strapi 4, so it does not run at all on Strapi 5. The Strapi 5 fork of it was written over two days in October 2024, released as 0.0.1, and never touched again. Between them they still take around 780 downloads a day, and the fork has 24 forks of its own, which is what "I had to patch it myself" looks like from the outside.

Its open issues say what is broken, and the two that matter are not details:

  • relations do not import, because the code carries Strapi 4's numeric id and a numeric id means a different row in every environment
  • components do not export, because populate: '*' stops one level down and a component inside a component comes back empty

Ferry is built around those two, and around the thing neither plugin has: it tells you what it is going to do before it does it.

Install

npm install @qkix/strapi-plugin-ferry
// config/plugins.ts
export default {
  ferry: {
    enabled: true,
  },
};

Then open Ferry in the main menu.

Exporting from the list view

Ferry's own page carries whole content types. The Content Manager is where you usually know which entries you actually want, so the export is there too, in the two shapes that question comes in:

  • Tick some rows and Export appears beside Delete. This is the "a copy of the twelve articles I just fixed" case.
  • Narrow the list with filters and the Export button next to the view settings takes everything that matches - including what is on the pages you cannot see, which is why ticking rows cannot answer it.

Either way you pick the format and the version, and the file downloads. Both respect the plugin::ferry.export permission and appear only on the project's own content types, so nothing offers an export the server would refuse.

The dry run

Nothing is written until you have read a report. Upload a file, and Ferry resolves the whole thing against the database first:

  • how many documents it will create, update and skip
  • every relation that points at a document which is neither in the file nor in this environment, named by row and by field, including seo.owner-style paths inside a component
  • which columns of a CSV it did not recognise, which media it could not find, which fields the format cannot carry

Then you press the button. It is the same request either way with one flag changed, so the preview cannot drift from what the import does.

Relations that arrive

Ferry keys everything on documentId, which is the one identifier that means the same thing in your laptop's database and in production. That is what makes an import an upsert rather than a paste: run the same file twice and the second run updates in place instead of duplicating the content.

The import runs in two passes - every document is written first, and the links between them second. There is no ordering of a single pass that works, because two documents that refer to each other cannot both be written second. A missing target is reported rather than discovered: Strapi refuses an entire row when one relation points at nothing, so an importer that just tries it loses content it never mentioned.

An import is all or nothing by default. If a row fails, the whole thing rolls back and the database is exactly as it was, because a half-applied import is worse than a refused one - nobody knows which half. You can opt into committing the rest.

Components, dynamic zones and media

Components are exported and imported whole, including a component inside a component, and including a relation inside a component. Component ids are dropped, because a component row belongs to one document and its id means nothing in another database. A dynamic zone keeps its order, because in a dynamic zone the order is the content.

Media travels as a reference, not as bytes. Ferry matches by the file's hash, which is derived from its contents, so the same image already uploaded to the target environment is reconnected even though its id and filename differ. When it is not there, the field is left empty and the report says how many.

A file you can commit

An export has no timestamp in it, and documents come out sorted by documentId with a to-many relation sorted too. Two exports of the same content are byte-for-byte identical, so the file can live in a repository and a diff means something changed. Git already knows when the file arrived.

{
  "ferry": 1,
  "contentType": "api::article.article",
  "status": "draft",
  "count": 1,
  "documents": [
    {
      "documentId": "k2n4xk1t9m0q8v7w",
      "title": "The first one",
      "author": "a91xbq2r7t4m0p3z",
      "tags": ["c7h2k9m1p4r8t0w3", "d1f5j8n2q6s9v3y7"],
      "seo": {
        "metaTitle": "Meta title",
        "canonical": { "href": "https://example.com/one", "label": "Canonical" }
      },
      "blocks": [
        { "__component": "shared.quote", "body": "A quote" },
        { "__component": "shared.seo", "metaTitle": "Inside a zone" }
      ]
    }
  ]
}

CSV

CSV is a real implementation of RFC 4180, not a split on commas: a comma inside a title, a quote inside a body, a line break inside a paragraph all survive the round trip. It reads a semicolon file, which is what a spreadsheet saves in every locale that uses a comma for decimals, and writes a byte order mark so Excel opens UTF-8 as UTF-8.

CSV carries flat fields and relation keys. Components and dynamic zones are left out, and the export says which ones by name rather than writing a JSON blob into a spreadsheet cell that no one can edit. Use JSON when you want everything.

One rule worth knowing: a blank cell means "leave this alone", not "empty this field". CSV cannot tell the two apart, and the safe reading is the one that does not let a spreadsheet quietly erase content that simply was not in it.

Permissions

Two, under Settings → Administration panel → Roles:

| Permission | What it allows | | -------------- | ------------------------------------------------- | | ferry.export | reading content out, and seeing the list of types | | ferry.import | the dry run and the import itself |

They are separate because they are different risks. An export is a copy; an import replaces what is there. A role that may take a copy away has no automatic claim on replacing production.

From a script

Both directions are plain admin endpoints, so anything with an admin session can drive them - a CI job, a seeding script, a migration between environments:

# Take a copy
curl -X POST http://localhost:1337/ferry/export \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"uid":"api::article.article","format":"json"}' \
  -o articles.json

# See what importing it would do, without doing it
curl -X POST http://localhost:1337/ferry/import/preview \
  -H "Authorization: Bearer $TOKEN" \
  -F uid=api::article.article \
  -F [email protected]

The import endpoints take a multipart upload, which keeps a large file clear of the JSON body limit, or a content field with the file inline for scripting convenience.

POST /ferry/export returns the file itself, which is what you want piped into -o. Pass "envelope": true to get it wrapped in JSON instead, as { filename, mime, body, count, warnings } - useful when you want the warnings alongside the content rather than in the X-Ferry-Warnings header, and what the admin panel itself uses.

| Route | Method | Does | | ----------------------- | ------ | ------------------------------------- | | /ferry/content-types | GET | what can be carried | | /ferry/plan/:uid | GET | which fields travel, and which do not | | /ferry/export | POST | the file | | /ferry/import/preview | POST | the dry run | | /ferry/import | POST | the import |

Configuration

| Option | Default | What it does | | ----------- | ------- | ------------------------------------------------------- | | exclude | [] | Content types Ferry will not carry, in either direction | | maxExport | 10000 | The most documents one export will read |

export default {
  ferry: {
    enabled: true,
    config: {
      exclude: ['api::invoice.invoice'],
    },
  },
};

An excluded type is refused by the endpoints as well as hidden in the picker: a hidden thing that can still be fetched by typing its uid is not hidden.

A password field is never exported, whatever the configuration says. A hash is still a credential, and files get mailed around and committed.

What it does not do

Said plainly, because finding out later is worse:

  • It does not carry files. Media travels as a reference and is reconnected by hash when the upload already exists in the target.
  • One content type per file. Export the types a relation points at first, and import them in that order. Links within a single file work in any order.
  • One locale per file. Pass locale to move a specific one.
  • No XLSX. CSV opens in the same places.

Tested against a real Strapi

The test suite boots an actual Strapi against a throwaway database rather than mocking one, and the headline test is the thing people actually do: export a whole environment, delete its content, import the files back, and assert that a second export is byte-for-byte the first.

Nine of the tests are about Strapi rather than about Ferry. They pin down the behaviour the design rests on, so that a Strapi upgrade that changes it fails a test here instead of quietly corrupting an import - that documentId can be assigned on create, that create is not an upsert, that a missing relation target takes the whole row down, and that a draft is allowed to be incomplete so required is no guide to what will import.

Roadmap

What is worth building next, what is deliberately not planned, and why: ROADMAP.md.

License

MIT