Migrating Drupal 7 to Next.js: A 9-Year-Old CMS Reborn
How I migrated a 9-year-old Drupal 7 site with 80K nodes, 200K users, and 14 years of SEO into a modern Next.js + headless CMS architecture without losing rankings or breaking URLs.
The Project
A 9-year-old Drupal 7 site. 80,000 nodes (articles, pages, custom content types), 200,000 registered users, 14 years of accumulated SEO authority, and a CKEditor-based content workflow nobody wanted to throw away.
Goals: modern UX, < 1s LCP, kept editorial workflow, zero URL breakage, zero SEO drop.
Architecture Decision
After evaluating a full rewrite, headless Drupal, and headless CMS options, I picked:
- Next.js 15 for frontend (App Router, RSC)
- Drupal 10 as headless CMS (keep editors happy, migrate from D7)
- JSON:API + GraphQL for data fetching
- ISR (Incremental Static Regeneration) for content pages
- Algolia for search
Step 1: Drupal 7 → Drupal 10 Migration
# Migrate Drupal core
drush migrate:upgrade --legacy-db-url=mysql://user:pass@host/d7db --legacy-root=/old/site# Run individual migrations drush migrate:import d7_user drush migrate:import d7_node_complete:article drush migrate:import d7_taxonomy_term drush migrate:import d7_file ```
This took 3 weeks. Custom modules required custom migrate plugins.
Step 2: Expose Content via JSON:API
# Enable JSON:API + cache headers
modules:
- jsonapi
- jsonapi_extras
- cache_control_override
// Custom: filter PHP eval security risk fields from JSON:API output
function mymodule_jsonapi_entity_filter_access(EntityInterface $entity) {
return AccessResult::forbiddenIf($entity->bundle() === 'internal_only');
}
Step 3: Next.js with ISR
// app/[...slug]/page.tsx
export const revalidate = 300 // 5 min ISRexport async function generateStaticParams() {
const res = await fetch(${DRUPAL_URL}/jsonapi/node/article?fields[node--article]=path)
const { data } = await res.json()
return data.map((n: any) => ({ slug: n.attributes.path.alias.split('/').filter(Boolean) }))
}
export default async function Page({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params
const path = '/' + slug.join('/')
const res = await fetch(
${DRUPAL_URL}/router/translate-path?path=${path},
{ next: { revalidate: 300, tags: [node-${path}] } }
)
const route = await res.json()
const node = await fetchNode(route.entity.uuid, route.entity.bundle)
return
Step 4: Webhook-Triggered Revalidation
When an editor saves in Drupal, trigger Next.js to revalidate:
function mymodule_node_update(NodeInterface $node) {
$path = $node->toUrl()->toString();
\Drupal::httpClient()->post(
getenv('NEXTJS_REVALIDATE_URL'),
[
'headers' => ['Authorization' => 'Bearer ' . getenv('REVALIDATE_TOKEN')],
'json' => ['paths' => [$path], 'tags' => ['node-' . $path]],
]
);
}
// app/api/revalidate/route.ts
export async function POST(req: Request) {
const auth = req.headers.get('authorization')
if (auth !== `Bearer ${process.env.REVALIDATE_TOKEN}`) return new Response('forbidden', { status: 403 })const { paths = [], tags = [] } = await req.json() paths.forEach(p => revalidatePath(p)) tags.forEach(t => revalidateTag(t)) return Response.json({ revalidated: true }) } ```
Step 5: SEO Preservation
| Concern | Solution |
|---|---|
| URL structure | Kept identical. Drupal aliases → Next.js dynamic routes |
| Redirects | Imported D7 `redirect` table into a Next.js middleware-based redirect map |
| Meta tags | All metatag module data exposed via JSON:API and rendered server-side |
| Sitemap | Generated dynamically from Drupal node list |
| Schema.org | Re-implemented Schema.org metatag module output in Next.js |
Results After 3 Months
| Metric | Before (Drupal 7) | After (Next.js + D10) |
|---|---|---|
| LCP | 4.2s | 0.9s |
| Lighthouse SEO | 88 | 100 |
| Organic traffic | baseline | +12% |
| Editor satisfaction | baseline | "love it" |
| Server cost | $1200/mo | $340/mo |
No URL broke. No ranking dropped. The 14 years of SEO authority transferred intact because we never changed a URL — we just changed what renders at each URL.