# Migrate from Drupal to Agility

> Source: https://agilitycms.com/docs/developers/migrate-from-drupal

> [!NOTE]
> 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.

## At a glance

1. **Inventory** content types, fields, vocabularies, media types and URLs ([step 1](#1-inventory-the-site)).
2. **Export** with the core JSON:API module ([step 2](#2-export-with-jsonapi)), or with Views data export or Drush ([step 3](#3-other-export-options)).
3. **Map** content types, Paragraphs, taxonomy, media and menus to Agility ([step 4](#4-map-to-agility)).
4. **Clean up** text fields: embedded media, internal links and file URLs ([step 5](#5-clean-up-text-fields)).
5. **Handle languages** if the site is multilingual ([step 6](#6-languages)).
6. **Import** in dependency order with the Management SDK or an AI agent ([step 7](#7-import-into-agility)).
7. **Switch over**: map path aliases and old redirects, then cut over ([step 8](#8-urls-and-redirects)).

This guide applies the phases in [Migrating to Agility](/docs/developers/migrating-to-agility) to a Drupal 10 or 11 site.

### Drupal versions

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](#4-map-to-agility) applies to every version.

## 1. Inventory the site

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:

- **Content types** and how many nodes each has. Mark which ones are lists of structured content (articles, events, people) and which are one-off pages.
- **Fields** on each content type, and which ones are actually populated.
- **Paragraph types**, if the site uses the contributed Paragraphs module, and which content types use them.
- **Taxonomy vocabularies**, whether each is flat or hierarchical, and which fields reference them.
- **Media types** (image, document, remote video) and the files behind them.
- **Custom blocks, menus and Views** that show content: footers, notices, listing pages.
- **Languages**, if the site uses the core Language and Content Translation modules.
- **Every public URL**: path aliases, the site's sitemap, existing redirects and your analytics. See [step 8](#8-urls-and-redirects).

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.

## 2. Export with JSON:API

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:

- **IDs are UUIDs.** Resources are identified by the entity's UUID, not its numeric ID. Store the UUID in a `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`.
- **Access applies.** JSON:API respects Drupal's entity access, so an anonymous request returns only what anonymous visitors can see. Use an account that can view everything you are migrating, including unpublished content if you want it.
- **Follow `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.
- **Include what you reference.** Relationship fields return `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`.
- **Text fields come in parts.** A formatted text field such as `body` has a `value` (the stored text), a `format` (the text format) and `processed` (the text with the format applied). See [step 5](#5-clean-up-text-fields).
- **Path aliases come with the entity.** A node's `path` field carries its `alias` and `langcode`. You need it for the URL map.
- **Revisions.** For nodes and media, the `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.

```ts
// 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)
```

## 3. Other export options

| Option | What you get | Use it when |
| --- | --- | --- |
| [Views data export](https://www.drupal.org/project/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](#1-inventory-the-site). 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.

## 4. Map to Agility

| 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](/docs/editors/generating-dynamic-pages-from-content). |
| Content type for one-off pages (Basic page) | Pages in the sitemap, built from a [page model](/docs/developers/page-models) and components | |
| Paragraph types used to build pages | [Component models](/docs/developers/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](#8-urls-and-redirects). |
| Languages | Agility locales | See [step 6](#6-languages). |

### Field types

| 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](/docs/developers/fields) and [Linked Content Field Types](/docs/developers/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.

## 5. Clean up text fields

Drupal's formatted text needs some work before it belongs on the new site:

- **Choose `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.
- **Replace embedded media.** Media embedded through the editor is stored in `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.
- **Move file URLs.** Images and files linked from text point at the site's public files directory. Upload each one and rewrite the URL to its `cdn.aglty.io` address.
- **Rewrite internal links.** Links can use an alias (`/about-us`) or a system path (`/node/12`). Point both at the new page paths, so readers don't go through a redirect.
- **Strip leftovers.** Remove wrapper `div`s, inline styles and classes that only the old theme used. Keep headings, lists, links and tables.

[Legacy Content Migration](/docs/developers/legacy-content-migration) covers parsing HTML into structured fields.

## 6. Languages

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](/docs/developers/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.

## 7. Import into Agility

Import in dependency order, keeping a map from Drupal UUID to Agility content ID, page ID or asset URL:

1. Files and media, uploaded to the media library.
2. Taxonomy terms (parents before children) and authors.
3. Custom blocks and other shared items.
4. Content items, with the child items or components that came from their Paragraphs.
5. Pages, then the components on each page.

Use the Management SDK, saving in sequential batches and checking every result, as shown in [Migrating Content into Agility with an AI Agent](/docs/developers/migrating-content-with-an-ai-agent#step-5-scale-up-with-a-management-sdk-script). Pages are created with the SDK's page methods; see [Pages](/docs/javascript/management-sdk/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](/docs/developers/migrating-to-agility#throughput-batches-limits-and-tokens).

## 8. URLs and redirects

Drupal sites usually have well-established URLs, so keep as many as you can:

- **List every URL.** Collect each node's and term's alias from the `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.
- **Keep paths where you can.** If aliases follow a pattern such as `/blog/{title}`, rebuild that structure with a dynamic page and import the last segment of the alias as the item's slug.
- **Carry over existing redirects.** If the site uses the contributed Redirect module, export its redirects too. Point each one at the final new URL, so there are no chains.
- **Load redirects** for every URL that changes into Agility's [URL Redirections](/docs/editors/url-redirections), with a file import or the Management API, which takes up to 250 redirects per call. Your front end applies them.
- **Freeze, sync and switch** using the [Migration Cutover Runbook](/docs/developers/migration-cutover-runbook). For the final sync, re-export the items whose `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](/docs/developers/keep-search-rankings-through-a-migration).

## Checklist

- [ ] Every content type, paragraph type, vocabulary and media type has a decision: model, component, list or leave.
- [ ] The export used an account that can see everything you are migrating, and followed `links.next` to the end.
- [ ] Counts in Agility match the export for each content type, minus anything skipped on purpose.
- [ ] No text field still contains `<drupal-media>` tags, old file URLs or `/node/` links.
- [ ] Each locale has the right values, with the same content ID across locales.
- [ ] Every changed URL, including old redirects, has a 301 redirect to its final destination.

## Related

- [Migrating to Agility](/docs/developers/migrating-to-agility)
- [Migrating Content into Agility with an AI Agent](/docs/developers/migrating-content-with-an-ai-agent)
- [Legacy Content Migration](/docs/developers/legacy-content-migration)
- [Migration Cutover Runbook](/docs/developers/migration-cutover-runbook)
- [Keep Your Search Rankings Through a Migration](/docs/developers/keep-search-rankings-through-a-migration)
- Drupal: [JSON:API module](https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module), [Drush commands](https://www.drush.org/13.x/commands/all/)

---

## Related pages

- [Migrating to Agility](https://agilitycms.com/docs/developers/migrating-to-agility.md)
- [Migrate from WordPress to Agility](https://agilitycms.com/docs/developers/migrate-from-wordpress.md)
- [Migrate from Contentful to Agility](https://agilitycms.com/docs/developers/migrate-from-contentful.md)
- [Migrate from Sitecore to Agility](https://agilitycms.com/docs/developers/migrate-from-sitecore.md)
- [Migration Cutover Runbook](https://agilitycms.com/docs/developers/migration-cutover-runbook.md)
- [Keep Your Search Rankings Through a Migration](https://agilitycms.com/docs/developers/keep-search-rankings-through-a-migration.md)
- [Migrate from SharePoint or Power Pages to Agility](https://agilitycms.com/docs/developers/migrate-from-sharepoint-and-power-pages.md)

Index of every docs page: https://agilitycms.com/docs/llms.txt
