Migrating from Decap CMS to a hosted CMS, step by step
Decap is free and maintained at a minimum. If you are moving an Astro site off it, here is the actual sequence, what breaks, and how to keep a rollback that works.
Decap CMS, previously Netlify CMS, runs a large number of small sites and is now maintained at a minimum: releases are rare, the issue tracker is long, and much of the community has drifted to Sveltia or elsewhere.
That is not a reason to move on its own. Software that works and stops changing is not a crisis. The reasons to move are usually more concrete: the GitHub account your client cannot manage, the twenty configuration files you maintain by hand, or the build that fails silently while everyone believes the site was updated.
If you have decided to move an Astro site, this is the sequence that works.
Before anything: decide what you are actually fixing
Migrations fail when nobody wrote down the problem. Pick from this list, honestly:
- Client friction. They cannot manage the GitHub account, the auth flow confuses them, or they are afraid of breaking something.
- Fleet maintenance. A config file per site, breaking changes to absorb on each.
- Media. Repository weight, no image pipeline, alt text nobody filled in.
- The silent failure. No feedback loop between publishing and being live.
- Longevity. You are uncomfortable betting a client’s site on a project in maintenance mode.
If none of these bite, do not migrate. Decap is free and it works.
Step 0: know what you have
Decap’s model is a config.yml describing collections and fields, plus markdown files with frontmatter.
Take an inventory before touching anything:
# What collections exist, and how many entries in each
ls -1 src/content/*/ | head -50
# What frontmatter keys are actually in use
grep -h '^[a-z_]*:' src/content/**/*.md | cut -d: -f1 | sort | uniq -c | sort -rn
# How heavy is the media, and is it in Git
du -sh public/images/ src/assets/ 2>/dev/null
git count-objects -vH | grep size-pack
That third command is often the moment people realise how much dead image data is in their history.
Note anything unusual: Decap widgets with no clean equivalent, list fields containing objects, relation widgets pointing across collections. Those are the parts that need decisions rather than mechanical translation.
Step 1: write the schema
Your Decap config.yml becomes a TypeScript file. The mapping is mostly direct:
| Decap widget | Equivalent |
|---|---|
string |
fields.text() |
text |
fields.textarea() |
markdown |
fields.richtext() |
number |
fields.number() |
boolean |
fields.boolean() |
datetime |
fields.datetime() |
date |
fields.date() |
select |
fields.select() |
image |
fields.image() |
file |
fields.file() |
object |
fields.group() |
list (of objects) |
fields.repeater() |
relation |
fields.relation() |
Two mappings need thought rather than translation.
list of plain strings. Decap lets you have a list of bare strings. There is no direct equivalent, and the honest answer is a repeater with one text field inside, which is slightly heavier in the editor and much clearer in the data.
Hidden and computed fields. Decap has hidden widgets holding values the editor never sets. Those usually belong in your templates rather than in content, and a migration is a good moment to move them there.
Step 2: import, do not retype
This is the step that saves the day, because your content is already markdown in Astro content collections, which is exactly what the importer reads:
npx menestrel import
It scans your collections, infers the shape from what is actually present, uploads images and rewrites references. On a brochure site it usually runs in a few minutes.
Run it against a fresh project, not the one you intend to keep, the first time. Look at what came out, adjust the schema, and run it again. It is resumable and idempotent: interrupt it and re-run, and it picks up where it stopped without duplicating.
Things worth checking in the result: dates that were strings and are now dates, markdown that used raw HTML, image paths that were relative in a way the scanner could not resolve, and any entry where required fields turned out to be empty in the source.
Step 3: swap the loader
The Astro side is the smallest change:
import { defineCollection } from 'astro:content';
import { menestrelLoader } from '@menestrel/astro';
export const collections = {
services: defineCollection({ loader: menestrelLoader({ collection: 'services' }) }),
};
Your page components do not change. getCollection(), render() and typing behave exactly as before, because Content Layer made the source pluggable. That is the part that makes this migration cheap.
Images are the one place templates usually need touching: paths become objects carrying dimensions and alt text, which is an improvement but not a no-op.
Step 4: keep a rollback that works
Do not delete anything. Specifically:
- Keep the Decap config and the markdown files on a branch. Not in a folder you renamed: a branch you can check out.
- Keep Decap’s admin route deployed until you are done, behind the same auth as before.
- Write down the revert: check out the branch, redeploy. One sentence, tested once, before you need it.
A migration without a tested rollback is not a migration, it is a leap.
Step 5: move the client, not just the content
The technical part is the easy half. The client has muscle memory for a tool that is about to disappear.
What works: do their real task in front of them, once, then hand over the mouse and watch them do it. Not a tour of the interface, an actual edit of an actual page they care about.
What to tell them explicitly, because it is the one genuine behaviour change: publishing now takes a minute or two, and the interface will tell them when it is really live. Under Decap, saving felt instant and being live was an unknown. Now saving is instant and publishing is visible and takes a moment. That is better, and it surprises people if nobody says it first.
Step 6: deal with the repository weight
Once content has moved, you are left with a repository carrying every version of every photo the client ever uploaded. Removing the files from the working tree does not shrink it: Git keeps the history.
You have three options, in increasing order of disruption.
Do nothing. Perfectly valid. A few hundred megabytes is annoying on a fresh clone and irrelevant the rest of the time. If the site is stable and the team is you, ignore it.
Archive and start clean. Tag the current state, keep the old repository as an archive, and initialise a new one from the current tree. You lose browsable history in the active repo and keep it in the archive. This is usually the pragmatic choice for a client site, where nobody has ever needed to read a three-year-old commit.
Rewrite history with git filter-repo to drop the image blobs. It works, it produces a genuinely small repository, and it rewrites every commit hash, which breaks every existing clone and every reference to a commit. Only worth it if the repository is shared and large enough to hurt.
Whichever you pick, do it after the migration is confirmed working and the rollback branch is no longer needed, not before.
A realistic timeline
For a single brochure site with a handful of collections and a couple of hundred images, done properly rather than heroically:
| Step | Time |
|---|---|
| Inventory and decisions | 30 minutes |
| Writing the schema | 30 to 60 minutes |
| First import, review, adjust, re-import | 1 hour |
| Swapping the loader and fixing image templates | 1 hour |
| Testing, including a full build and a visual pass | 1 hour |
| Client handover | 20 minutes |
So roughly half a day, most of which is review rather than typing. The second site takes half that, because the schema is a file you copy.
Budget more if you used the editorial workflow, if you have relations across collections, or if your markdown contains raw HTML. Those three are where the hours go.
What actually breaks
From doing this on real sites:
Dates. Decap’s datetime stores strings in whatever format the config specified. Timezones are where the surprises live. Check a few entries by hand.
Raw HTML in markdown. Rich text is structured, so a stray <div> in a markdown body does not survive intact. Grep for < in your content before migrating and decide what to do with the hits.
Relations by file path. Decap relations often key on a path. Paths change. Anything relational deserves a manual look after import.
Draft state. Decap’s editorial workflow, if you used it, has no direct equivalent. Entries arrive as drafts or published; branch-based review does not carry over.
When not to do this
Being straight about it: if you are the only editor, Decap costs nothing and works, and this migration buys you very little. Stay.
If your client genuinely requires content to live in their repository, we cannot meet that requirement at all, and Sveltia or Keystatic are the better moves.
And if the thing bothering you is Decap’s maintenance pace rather than anything it does, Sveltia is a much smaller change than moving to a hosted service.
The full comparison goes through those trade-offs, including what the free tools still do better.