Skip to content
aviral gupta

Laravel to Next.js Migration Without Losing Rankings

A Laravel to Next.js migration keeps its rankings when every URL the old application answered, including trailing-slash, id-based and query-string variants, reaches its new page in one 301 hop, and that is proven row by row on staging before DNS moves.

Written by Aviral GuptaPublished 14 min read
  • Migration
  • Laravel
  • Next.js
  • Technical SEO
Laravel to Next.js Migration Without Losing RankingsPRE-LAUNCH GATEcanonical resolves 200sitemap URLs not redirectshreflang returns bidirectionalcontent in SSR HTMLschema @graph validIndexNow key reachable100SHIP GATE

What makes a Laravel to Next.js migration safe for rankings?

In 2024 I migrated Thrifty UAE's PHP Laravel monolith to Next.js while the platform kept taking bookings. It came through with no ranking loss and no downtime. What made it safe was unglamorous: a complete URL inventory, a 301 redirect map tested one to one, metadata and schema parity checked page by page, and a launch-day crawl comparing the old site with the new. The outcome is on the work page.

This post is the method, written as how to do it rather than as a record of that site. The URL contract decides the outcome, not the framework on either side of it. Laravel and Next.js each have defaults that quietly add or remove URLs, and most of the work is finding those defaults before they find you.

A server-rendered Laravel site is a good starting point. The body copy is already in the HTML, so the parity target for the new site is "do not regress" rather than "start rendering". That matters more every year: instrumented testing found GPTBot downloading JavaScript on roughly 11.5% of requests and ClaudeBot on 23.8%, and never executing it (SearchOptimo, 2026). A rebuild that moves copy behind a client boundary trades a working old stack for an invisible new one.

Where does the URL inventory come from on a Laravel site?

From five sources, reconciled into one table keyed on a normalised path. Nothing gets designed, redirected or deleted until that table exists, because until it exists every priority decision is somebody guessing which pages matter.

  1. The route table. php artisan route:list --except-vendor lists every route the application defines, including any Route::redirect entries. It gives you patterns such as articles/{article}, not URLs, so it tells you which shapes exist rather than how many pages each one produces.
  2. The database, for every bound route. Each route with a model parameter produces one URL per row. Generate those URLs with the same route() helper the application uses, so the list matches what the site actually links to.
  3. Search Console, Performance, Pages, exported over the longest range it offers: clicks, impressions and average position per URL. This is both the ranked list and the baseline you compare against after launch.
  4. Server logs, 30 to 90 days, filtered to Googlebot and Bingbot. This is the only source that finds URLs with no inbound link and no sitemap entry that still return 200 and still get fetched.
  5. A full crawl with JavaScript rendering off, plus every XML sitemap the site publishes and the internal-links export. You need the link graph, not just a list of addresses.
<?php

use App\Models\Article;
use Illuminate\Support\Facades\Artisan;

// php artisan urls:export > urls.csv
Artisan::command('urls:export', function () {
    $this->line('path,route');

    // One row per model, generated by the router itself rather than by
    // string concatenation, so it cannot disagree with the live links.
    foreach (Article::query()->orderBy('id')->lazy() as $article) {
        $this->line(route('articles.show', $article, false).',articles.show');
    }
});
A closure command in routes/console.php. With {article:slug} in the route or getRouteKeyName() on the model, route() writes the same key the live site links with. Pass false as the third argument for a path rather than an absolute URL.

Then normalise, then join. Strip the origin, strip the trailing slash, and decide per query parameter whether it defines a distinct page or is noise. Two rows that normalise to the same key are the same page, however many ways the old site spelled it.

# Reconcile the exports into one URL table. sqlite3 is enough for this,
# and the database file is worth keeping after the migration.
sqlite3 migration.db <<'SQL'
.mode csv
-- With .mode csv and a table that does not exist yet, sqlite3 takes the
-- first row of the file as the column names.
.import gsc-pages.csv gsc
.import urls.csv      routes
.import bot-hits.csv  logs

-- One key per real page: no origin, no trailing slash.
CREATE VIEW norm AS
  SELECT url AS raw,
         rtrim(replace(url, 'https://www.example.com', ''), '/') AS key,
         CAST(impressions AS INTEGER) AS impressions,
         CAST(clicks AS INTEGER)      AS clicks
  FROM gsc;

CREATE TABLE inventory AS
  SELECT n.key,
         count(DISTINCT n.raw)                     AS variants,
         sum(n.impressions)                        AS impressions,
         sum(n.clicks)                             AS clicks,
         max(r.route)                              AS laravel_route,
         coalesce(max(CAST(l.hits AS INTEGER)), 0) AS bot_hits
  FROM norm n
  LEFT JOIN routes r ON rtrim(r.path, '/') = n.key
  LEFT JOIN logs   l ON rtrim(l.path, '/') = n.key
  GROUP BY n.key
  ORDER BY impressions DESC;
SQL
Search Console exports absolute URLs and logs record paths, so strip the origin before joining. Check that numeric columns were imported as numbers before you trust the sort.

The output is one row per page, carrying every variant that resolved to it, its impressions, the Laravel route that served it and its bot hits. Priority then becomes arithmetic: sort by impressions, take the running share, and verify the URLs above the 80% line individually before launch. Everything below it is verified by rule and spot-checked. A row with impressions but no Laravel route is a URL the application answered by accident, and those are exactly the ones a rebuild forgets. The join above only matches exact paths; run a second pass for the rows it leaves without a route.

Audit checklist: URL inventory, redirect map, metadata parity, internal links, cutover and 30-day watch, each with a verification method.PRE-LAUNCH GATEcanonical resolves 200sitemap URLs not redirectshreflang returns bidirectionalcontent in SSR HTMLschema @graph validIndexNow key reachable100SHIP GATE
Every row in the runbook has a verification method attached. A step with no way to check it is a hope, not a step.

Which Laravel URL shapes need their own redirect rules?

Four, and each comes from a default rather than from anything a developer chose on purpose.

  • Trailing slashes. Laravel's default public/.htaccess answers /about/ with a 301 to /about. The Nginx configuration in Laravel's deployment guide has no such rule, and the router matches /about/ to the same route as /about, so behind Nginx both forms return 200 and either can be linked and indexed. Check the logs for which form was actually requested before deciding it does not matter.
  • Model-bound URLs keyed by id. A route like /articles/{article} resolves the model by its primary key unless the route says {article:slug} or the model overrides getRouteKeyName(). If the old site used ids and the new one uses slugs, every id URL needs a row in the map, generated from the database, never typed.
  • State in the query string. Laravel's paginator writes ?page=2, and list filters usually arrive as query parameters too. Each distinct string can be crawled as a separate URL. Decide per parameter: one that defines a page with real demand becomes a path segment, and one that only reorders or decorates the same content is dropped.
  • Redirects the old application already had. Route::redirect returns a 302 unless you give it a status code; Route::permanentRedirect returns a 301. Any of these that survive into the new map as-is create a chain: old URL, old redirect target, new URL. Resolve each to its final destination before it goes into the map.

One more is worth a grep through the logs: paths that start with /index.php/. Laravel runs everything through that front controller, and depending on the web-server configuration a request such as /index.php/about can reach the application and return the page. If the logs show them being fetched, they get rows in the map like any other variant.

How do you choose the new URL structure without spending equity?

One rule: change a URL only where the change is forced, and where it is forced, change it exactly once. A replatform is the moment everyone wants to fix the slugs they have disliked for years. Do that on the same day you change the platform, the rendering and the templates, and you will never know which change moved which number.

  • Keep the slash policy the old canonicals used. If the canonical tags and sitemap named /about, the new site serves /about, and most URLs need no redirect at all. Next.js defaults to no trailing slash; trailingSlash: true in next.config reverses it.
  • Keep the slug wording. If a slug is genuinely bad, change it in a separate release, weeks later, with its own before-and-after.
  • Change id URLs to slugs only if you mean to. It is a legitimate improvement, but it turns every model-bound URL into a redirect. Doing it in the same release is defensible only when the map is generated from the database and tested row by row.
  • One canonical path per page. Duplicates become redirects, not canonical tags. A canonical is a hint, a 301 is an instruction, and this is a case where you want the instruction.

How do you implement a one-hop redirect map in Next.js 16?

The specification is four words long: one hop, one 200. It is difficult only because Next.js can redirect in three places that stack on each other: its built-in trailing-slash redirect, the redirects list in next.config, and the proxy.

URL shapes from a typical Laravel site, and the one layer that handles each.
Legacy URL shapeWhy it existedDestinationWhich layer
/articles/42Implicit route model binding by primary key/articles/some-slugproxy.ts, from a map generated from the database
/articles/some-slug/Nginx served both slash forms with a 200/articles/some-slugproxy.ts, slash stripped and map checked in the same hop
/articles?category=newsA filter with real search demand behind it/articles/category/newsnext.config.ts, using has with a named capture
/index.php/aboutThe front controller path, reachable by some server configurations/aboutnext.config.ts, because the dot keeps it away from the proxy
// next.config.ts — Next.js 16
import type {NextConfig} from 'next';
import {LEGACY_MAP} from './lib/legacy-map'; // generated from the inventory

const nextConfig: NextConfig = {
  // Slash normalisation moves into proxy.ts. Without this, the built-in
  // 308 for '/old-page/' runs before any rule below and costs a second hop.
  skipTrailingSlashRedirect: true,

  async redirects() {
    return [
      // Paths with a dot never reach the proxy matcher, so index.php
      // variants live here, pointing straight at the final destination.
      {source: '/index.php', destination: '/', statusCode: 301},
      ...Object.entries(LEGACY_MAP).map(([from, to]) => ({
        source: `/index.php${from}`,
        destination: to,
        statusCode: 301
      })),

      // A named capture group in `value` makes the match usable in the
      // destination. Parameters that were never a distinct page simply do
      // not appear on the right-hand side.
      {
        source: '/articles',
        has: [{type: 'query', key: 'category', value: '(?<category>[a-z0-9-]+)'}],
        destination: '/articles/category/:category',
        statusCode: 301
      }
    ];
  }
};

export default nextConfig;
Reference: redirects in next.config. Query values in the request are passed through to the destination, so the new route must treat unknown parameters as noise and keep its canonical clean.

permanent: true sends a 308; statusCode: 301 sends a 301. Use one or the other on a rule, not both. Search engines treat the two alike, but a 301 is what old link checkers and log pipelines expect. Keep the static list short, too: the Next.js redirecting guide notes that platforms can cap it (Vercel's limit is 1,024) and points to the proxy for maps larger than that.

// proxy.ts — Next.js 16
import {NextResponse, type NextRequest} from 'next/server';
import {LEGACY_MAP} from './lib/legacy-map';

export function proxy(request: NextRequest) {
  const {pathname} = request.nextUrl;

  // skipTrailingSlashRedirect is on, so this is the only place a trailing
  // slash is removed. Doing it here, together with the map lookup, is what
  // keeps '/articles/42/' to one hop instead of two.
  const key = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
  const target = LEGACY_MAP[key] ?? key;

  if (target !== pathname) {
    const url = request.nextUrl.clone();
    url.pathname = target;
    return NextResponse.redirect(url, 301);
  }
  return NextResponse.next();
}

export const config = {
  // Everything except API routes, Next.js internals and paths with a dot.
  matcher: ['/((?!api|_next/static|_next/image|.*\\..*).*)']
};
The proxy handles every dot-free legacy path: one normalisation, one lookup, one hop. Next.js 16 renamed middleware.ts to proxy.ts.

Then verify by script, never by clicking. The map is a list of pairs, so the test is too: request every legacy path, follow redirects, and assert three things per row. The status is 200, there was exactly one redirect, and the final URL is the mapped destination. URLs that did not change get their own list, asserting zero redirects. Any failing row blocks launch until it is fixed or written down as an accepted exception, and the same script is the first thing to run against production after cutover.

# redirect-map.csv: legacy_path,expected_path — generated, never typed.
ORIGIN=https://staging.example.com

tail -n +2 redirect-map.csv | while IFS=, read -r from to; do
  read -r code hops final < <(curl -sS -o /dev/null -L --max-redirs 5 \
    -w '%{http_code} %{num_redirects} %{url_effective}\n' "$ORIGIN$from")
  if [ "$code" != 200 ] || [ "$hops" != 1 ] || [ "$final" != "$ORIGIN$to" ]; then
    echo "FAIL $from -> $final ($code, $hops hops)"
  fi
done
One line of output per failing row. An empty result is the pass condition.

How do you prove metadata and structured data parity before launch?

By exporting the old values, not rewriting them. On a Laravel site titles and descriptions are usually assembled in Blade layouts from database fields, so the reliable source is the rendered HTML, not the templates: crawl the live site, extract each title, description, canonical and H1, and ship them as a fixture the new build reads. generateMetadata then returns the same string the old page returned. Improve the copy afterwards, in its own release, where the effect can be attributed to it.

  • Set `metadataBase` in the root layout to the absolute production origin. Without it, canonicals and Open Graph URLs resolve against whichever host is serving, which on staging means canonicals advertising the staging domain.
  • Canonicals absolute, self-referential, on the host you actually serve. Google papers over a mismatch; Bing does not. Conflicting canonicals are a documented cause of Bing indexing the wrong URL or nothing at all (Microsoft Q&A, 2026).
  • Robots directives checked in both directions. Whatever was noindex stays noindex, and more urgently, nothing new becomes it. A staging-wide noindex shipped to production undoes years of work in an afternoon.
  • Structured data types match or improve. Diff each old URL against its staging counterpart in the Rich Results Test. Dropping a BreadcrumbList sitewide is a rich-result regression, not a design decision.
  • Rendered word count within a few per cent, measured by crawling staging twice: once with JavaScript rendering on and once with it off. A gap between the two runs is copy that only exists after hydration.

A site with a booking or checkout funnel adds one category the diff has to enforce from the opposite direction. Search results, date selection and checkout steps are parameter explosions that should be noindex on the old platform and must be noindex on the new one from the first deploy, absent from the sitemap, and absent from crawlable link modules. For those URLs the pre-launch question is not "did they come across" but "are they still excluded".

How do you cut over without downtime and keep a fast rollback?

  1. 1

    T-7d: drop the DNS TTL

    Lower it to 300 seconds and let the old value expire from resolvers. The TTL is your rollback budget: it is how long a mistake stays live after you decide to undo it.

  2. 2

    T-72h: run the new platform in parallel

    The new build live on its own hostname, noindex and access-restricted, with the full redirect map active and the verification script passing against it. The Laravel application stays authoritative; the new one answers the same URLs correctly.

  3. 3

    T-24h: freeze content, re-export, re-diff

    Anything published on the old site after the final export does not exist on the new one. Re-export metadata and re-run the parity diff. Assume nothing you have not re-measured today.

  4. 4

    T-0: cut over and verify from outside

    Point DNS. Then fetch the top legacy URLs by impressions from a machine that has never seen the site, not the browser that has held the staging cookie all week, and assert one hop to a 200.

  5. 5

    T+15m: robots.txt, sitemaps, firewall

    Confirm production robots.txt is not the staging deny-all. Submit the new sitemaps in Search Console and Bing Webmaster Tools. Keep any managed bot ruleset in log-only mode: 2026 crawlability data from Anagram found 17.6% of top sites that allow GPTBot in robots.txt return 403 to it in practice.

  6. 6

    T+1h: tracking parity

    GA4, GTM and ad pixels firing on the new templates with the same event names and parameters as the day before. Keeping rankings and losing conversion data still reads as a failed migration to everyone outside engineering, so this belongs on the cutover checklist rather than after it.

  7. 7

    T+24h: first crawl-stats read

    Search Console, Settings, Crawl stats. Successful requests rising, no spike in 404 or 5xx, average response time inside the pre-launch range.

The rollback plan is one sentence and it has to be true: DNS is the switch, and the Laravel application stays running on its own hostname until the watch window closes. That is also what makes zero downtime achievable, because at no point is there a moment when neither system answers. Keep the old sitemap reachable for a few weeks too, if you control it: Google's site-move guidance says leaving old URLs discoverable speeds up how fast they are crawled and reprocessed. The Change of Address tool does not apply; it is for a change of domain, and a rebuild on the same domain does not use it.

What do you watch for 30 days after cutover?

Daily for week one, then twice weekly. The whole job is separating normal re-crawl wobble from a real defect, and the only way to do that is against the baseline exported before launch, compared per URL pair, never against a site total. Totals hide a page that lost everything behind a page that gained.

The 30-day watch. Every row has a source, an expected shape and a defined trigger.
SignalSourceHealthy patternTrigger to act
Indexation by reasonSearch Console, Page indexingPage with redirect rises, then plateausNot found (404) still climbing after week one
Server errorsSearch Console, Page indexingFlat at the pre-launch numberAny Server error (5xx) at all
Redirect volumeServer logs, status code by day301 volume decays week over weekStill flat at month one: something still links old URLs
Crawl response timeSearch Console, Settings, Crawl statsAt or below the old platformA steady climb, meaning crawlers are paying for cache misses
Per-URL search performanceSearch Console Performance export, joined to the baselineEach mapped pair back at its own pre-launch levelImpressions recover but position stays worse: a content problem, not a redirect one
Event parityGA4 realtime and DebugView, plus daily event countsSame event names, same parameters, comparable volumeAny pre-launch event missing for 24 hours
BingBing Webmaster Tools, URL Inspection and sitemap reportSitemap discovered, no redirect errors on submitted URLsA canonical mismatch on any sampled URL

One nuance: the field Core Web Vitals report is a 28-day rolling window. For roughly a month after cutover it mixes old-platform and new-platform sessions, so a flat line there in week two means nothing. Read lab numbers and your own real-user measurement for that month, and treat the field report as the confirmation that arrives late.

What should "no ranking loss" mean when you report it?

Define it before launch, so nobody redefines it afterwards. A useful definition: per-URL impressions and average position for the mapped set, each compared with its own pre-cutover baseline in Search Console, hold through the watch window. Not a keyword tracker showing a green arrow, and not a site-wide total.

  • Measure: per-URL impressions, clicks and position against the baseline, joined on the old-to-new pair.
  • Measure: page-indexing counts by reason, and the daily status-code distribution from server logs.
  • Measure: redirect behaviour, by re-running the one-hop script against production rather than trusting the config.
  • Do not claim: that the migration improved rankings. Search results move for reasons unrelated to any migration: algorithm updates, competitors, seasonality. The honest statement is that nothing broke.

What should you know about how I work on this?

Yes, as a method rather than a promise, because nobody can guarantee a search ranking. The method is a complete URL inventory built from the route table, the database, Search Console, server logs and a crawl; a redirect map generated from that table, one hop per legacy URL and no catch-all to the home page; metadata and structured-data parity checked on staging; and 30 days of monitoring. That is the approach behind my 2024 migration of Thrifty UAE from a PHP Laravel monolith to Next.js, which came through with no ranking loss and no downtime.

Yes, and it is often the safer order. The front end carries the URLs, the metadata and the tracking, so moving it first isolates the search risk in one release. Next.js can read the data the Laravel application already serves while the backend stays as it is, and the backend can then be replaced later behind a stable URL contract. Changing the rendering layer and the data layer on the same day makes any movement impossible to attribute.

Use whichever form the old canonical tags and sitemap used, so most URLs need no redirect at all. Next.js removes trailing slashes by default and `trailingSlash: true` reverses that. Whichever you pick, the other form still needs one hop to the canonical, and if you also have a redirect map, handle the slash in the proxy together with the map lookup so a legacy URL with the wrong slash does not pay two hops.

Either is a permanent redirect to search engines. Next.js sends 308 for `permanent: true` and 301 when you set `statusCode: 301`. A 301 is the safer choice for a migration because older link checkers, log pipelines and monitoring tools all recognise it. What matters far more than the code is that each legacy URL reaches its final page in exactly one hop.

No, and I would be wary of anyone who claims it can. Positions are decided by Google against competitors who are also changing. What you can control is every technical cause of a migration-driven drop: redirect chains, unmapped URL variants, lost internal links, drifted titles and canonicals, copy that only exists after hydration, and slower responses. Those get engineered out and verified, and the per-URL baseline means any movement can be attributed rather than argued about.

Indexation by reason, server errors, redirect volume in the logs, crawl response time, per-URL search performance against the baseline and analytics event parity, checked daily in week one and twice weekly after that for 30 days. When the window closes, the redirect map and the inventory table should be committed to the repository so they outlive the migration.

// OPEN TO WORK

Hiring a senior full stack engineer?