What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Migrating to Agility
Export a Drupal 10 or 11 site with the core JSON:API module, Views data export or Drush, then map content types, fields, Paragraphs, taxonomy, media, menus and path aliases to Agility models, pages, locales and redirects.
We last checked this guide on November 5, 2026, against Drupal.org's documentation for Drupal 10 and 11, the core JSON:API module and Drush 13. Drupal sites differ in their contributed modules and configuration, so if something here doesn't match your site, the Drupal documentation for your version has the latest.
A Drupal site is already structured: content types with fields, taxonomy vocabularies, media entities and, often, Paragraphs. Most of that maps cleanly to Agility models and linked content. The work is in the parts Drupal spreads across modules: page layouts, menus, path aliases and redirects.
This guide applies the phases in Migrating to Agility to a Drupal 10 or 11 site.
Drupal 7 security support ended on January 5, 2025, and Drupal.org lists December 9, 2026 as the end of life for Drupal 10, with Drupal 12 scheduled for release that same week. If you are facing a major-version upgrade anyway, that is a natural point to migrate.
The export steps below target Drupal 10 and 11. Drupal 7 doesn't have the JSON:API module in core, so a Drupal 7 site usually needs a scripted export or a database read, and you should check which tool versions still support it. The mapping in step 4 applies to every version.
Start with the model, not the content. Drupal stores its content types, fields, vocabularies and media types as configuration, and drush config:export (alias cex) writes that configuration to a directory as YAML files. Each field has a field.storage.* file and a field.field.* file per content type that uses it, so you can list every field and its type without opening the admin UI.
From the configuration and the site itself, record:
Also list what isn't content: Webforms and their submissions, comments, user accounts and commerce data. They stay in Drupal or move to whichever system will own them.
The JSON:API module ships with Drupal core. Enable it, and every content entity type and bundle gets its own collection URL:
| Request | Returns |
|---|---|
GET /jsonapi/node/{content type} | Nodes of one content type, for example /jsonapi/node/article |
GET /jsonapi/taxonomy_term/{vocabulary} | Terms in one vocabulary |
GET /jsonapi/media/{media type} | Media entities of one media type |
GET /jsonapi/file/file | File entities, with their uri and url |
GET /jsonapi/user/user | User accounts (export only the public fields you show) |
GET /jsonapi/node/{content type}/{uuid} | One node |
There's no single collection for all nodes; request each content type separately. JSON:API's read-only mode is on by default, which is all an export needs.
What to know before you script it:
SourceID Text field so re-runs update items instead of duplicating them. Keep the numeric node ID too if old links use system paths such as /node/123.links.next. A page holds at most 50 resources. Resources the account can't access are removed after the query, so a page can be shorter than the limit and still have a next page. Keep following links.next until a response has none, and don't stop at a short page.type and id pairs. Add include with the relationship field names to get the referenced entities in the same response, in a top-level included array, for example ?include=field_image,field_tags.body has a value (the stored text), a format (the text format) and processed (the text with the format applied). See step 5.path field carries its alias and langcode. You need it for the URL map.resourceVersion parameter requests a specific revision, for example rel:working-copy for the latest draft. Decide whether you are migrating published content or the latest drafts, and tell content owners.// Read every resource in a JSON:API collection by following links.next.
async function fetchAll(url: string, auth: string) {
const data: any[] = []
const included: any[] = []
let next: string | undefined = url
while (next) {
const res = await fetch(next, {
headers: {Accept: "application/vnd.api+json", Authorization: auth},
})
if (!res.ok) throw new Error(`${res.status} for ${next}`)
const body = await res.json()
data.push(...body.data)
if (body.included) included.push(...body.included)
next = body.links?.next?.href
}
return {data, included}
}
const articles = await fetchAll("https://example.com/jsonapi/node/article?include=field_image,field_tags", auth)
const tags = await fetchAll("https://example.com/jsonapi/taxonomy_term/tags", auth)
| Option | What you get | Use it when |
|---|---|---|
| Views data export (contributed module) | A View exported as CSV, XML or JSON (XLS with the Excel Serialization module) | You want a flat file per content type, or a spreadsheet for people to review. For large exports, use its batch display and sort the View on a unique ID such as nid. |
drush php:script | Your own PHP script, run after a full Drupal bootstrap with Drupal's entity API available | You need data JSON:API doesn't expose, such as existing redirects, or you'd rather write the export in PHP. |
drush config:export | The site's configuration as YAML | The inventory in step 1. It holds the model, not the content. |
drush sql:dump | The whole database as SQL | Last resort. You have to reassemble entities, fields, revisions and translations from many tables yourself. |
Whichever option you use, export from a copy of the production site, or during a quiet period, so the export doesn't slow down the live site.
| Drupal | Agility | Notes |
|---|---|---|
| Content type for structured content (articles, events, people) | Content model plus a list container, and a dynamic page for each item's URL | See Generating Dynamic Pages From Content. |
| Content type for one-off pages (Basic page) | Pages in the sitemap, built from a page model and components | |
| Paragraph types used to build pages | Component models placed in zones | Aim for a small set of components editors can understand, not a copy of every paragraph type. |
| Paragraph types used as repeating parts of one item (a list of FAQs, a set of stats) | Nested linked content: a list of child items on the parent | Paragraphs belong to one host entity, so don't import them as shared items. |
| Taxonomy vocabulary | A content model and list, such as Category or Tag | Term reference fields become Linked Content. Keep a parent term as a linked field, or flatten the hierarchy. A small fixed vocabulary can be a Drop-down List instead. |
| Media entities and files | The Agility media library | Upload the file, then reference the returned cdn.aglty.io URL from an Image or File field. |
| Remote video media | A URL field holding the video URL | |
| Users shown as authors | An Author content model and list | Import only the public fields your site shows. |
| Custom blocks | Single-item content, or a shared list | Footers, notices and other reused text. |
| Menus | The sitemap, or a Links list for menus that don't follow the page structure | Menu links to content use URIs such as entity:node/12. Resolve them to the new page paths. |
| Views that list content | A listing component on a page, reading a list | Views are queries, not content. Rebuild them in your front end. |
| URL aliases | Page paths and dynamic page path formulas | See step 8. |
| Languages | Agility locales | See step 6. |
| Drupal field type | Agility field type |
|---|---|
| Text (plain) | Text |
| Text (plain, long) | Multi-Line Text |
| Text (formatted), Text (formatted, long) | HTML |
| Text (formatted, long, with summary) | HTML, plus a Multi-Line Text field for the summary if you use it |
| Boolean | True/False |
| Number (integer) | Number |
| Number (decimal), Number (float) | Decimal |
| List (text), List (integer), List (float) | Drop-down List |
| Date, Timestamp | Date/Time |
| Date range | Two Date/Time fields |
| Email, Telephone number | Text |
| Link | URL |
| Image | Image |
| File | File |
| Entity reference to a node, term or user | Linked Content (a dropdown for one item, a search list box for several) |
| Entity reference to media | Image or File, after uploading the file |
| Paragraphs | Nested linked content or components (see above) |
See Fields and Linked Content Field Types. A linked-content field stores its selection in two companion fields, _ValueField (the linked item's content ID) and _TextField. Read back an item you set by hand in the editor and copy that shape.
Drupal's formatted text needs some work before it belongs on the new site:
value or processed. processed is the HTML after the text format's filters ran, which is closest to what visitors saw. value is the stored source, which can still hold Drupal-specific markup. Pick one and clean it consistently.value as a <drupal-media> tag with data-entity-type and data-entity-uuid attributes. Look up each UUID in your media export, upload the file to the Agility media library, and replace the tag with an image or link to the new URL.cdn.aglty.io address./about-us) or a system path (/node/12). Point both at the new page paths, so readers don't go through a redirect.divs, inline styles and classes that only the old theme used. Keep headings, lists, links and tables.Legacy Content Migration covers parsing HTML into structured fields.
In Drupal, the core Content Translation module makes content entities translatable, and each field is set as translatable or not. Core JSON:API serves translations through the site's language negotiation, so request each language the way your site detects it, and record which language each response is in.
In Agility, the same content item has the same content ID in every locale. Create each item in your main locale, record its content ID, then save the same content ID in each other locale. See Creating Content and Pages in Other Locales. For a field that isn't translatable in Drupal, write the same value into each locale variant.
Path aliases are per language. Build the URL map per language, and limit each redirect to its locale where the languages use different URL prefixes.
Import in dependency order, keeping a map from Drupal UUID to Agility content ID, page ID or asset URL:
Use the Management SDK, saving in sequential batches and checking every result, as shown in Migrating Content into Agility with an AI Agent. Pages are created with the SDK's page methods; see Pages. An AI agent with the Agility MCP Server is a fast way to read a sample of the JSON:API export, propose models and import 10 to 20 items into Staging for review first. Throughput guidance is in Migrating to Agility.
Drupal sites usually have well-established URLs, so keep as many as you can:
path field, the site's sitemap, existing redirects and your analytics. Include system paths such as /node/123 and /taxonomy/term/45 if old links still use them./blog/{title}, rebuild that structure with a dynamic page and import the last segment of the alias as the item's slug.changed date is after your last full run.For the SEO side, including titles, meta descriptions and sitemaps, follow Keep Your Search Rankings Through a Migration.
links.next to the end.<drupal-media> tags, old file URLs or /node/ links.