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.
- Migration
- Laravel
- Next.js
- Technical SEO
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.
- The route table.
php artisan route:list --except-vendorlists every route the application defines, including anyRoute::redirectentries. It gives you patterns such asarticles/{article}, not URLs, so it tells you which shapes exist rather than how many pages each one produces. - 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. - 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.
- 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.
- 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');
}
});{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;
SQLThe 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.
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/.htaccessanswers/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 overridesgetRouteKeyName(). 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::redirectreturns a 302 unless you give it a status code;Route::permanentRedirectreturns 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: trueinnext.configreverses 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.
| Legacy URL shape | Why it existed | Destination | Which layer |
|---|---|---|---|
/articles/42 | Implicit route model binding by primary key | /articles/some-slug | proxy.ts, from a map generated from the database |
/articles/some-slug/ | Nginx served both slash forms with a 200 | /articles/some-slug | proxy.ts, slash stripped and map checked in the same hop |
/articles?category=news | A filter with real search demand behind it | /articles/category/news | next.config.ts, using has with a named capture |
/index.php/about | The front controller path, reachable by some server configurations | /about | next.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;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|.*\\..*).*)']
};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
doneHow 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
noindexstaysnoindex, and more urgently, nothing new becomes it. A staging-widenoindexshipped 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
BreadcrumbListsitewide 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 rebuild internal linking so pages keep their inbound links?
Every internal href in migrated content is rewritten at build time through the same map that drives the redirects, so no link on the new site points at a URL that then redirects. Re-crawl staging and assert that internal 3xx responses are zero. A link with no mapping is a bug in the map, not in the link: fix it in the table, where the fix applies everywhere.
Then compare link counts, not just link validity. Join the inbound-internal-links report from the staging crawl to the same report from the live site, one row per mapped pair. A long-running application accumulates navigation nobody has counted: footer directories, location grids, related-item blocks, pagination. A rebuild ships the links a designer drew. A page that received twenty internal links and now receives two has lost part of what made it rank, and no redirect returns that.
How do you cut over without downtime and keep a fast rollback?
- 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
T-72h: run the new platform in parallel
The new build live on its own hostname,
noindexand 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
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
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
T+15m: robots.txt, sitemaps, firewall
Confirm production
robots.txtis 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
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
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.
| Signal | Source | Healthy pattern | Trigger to act |
|---|---|---|---|
| Indexation by reason | Search Console, Page indexing | Page with redirect rises, then plateaus | Not found (404) still climbing after week one |
| Server errors | Search Console, Page indexing | Flat at the pre-launch number | Any Server error (5xx) at all |
| Redirect volume | Server logs, status code by day | 301 volume decays week over week | Still flat at month one: something still links old URLs |
| Crawl response time | Search Console, Settings, Crawl stats | At or below the old platform | A steady climb, meaning crawlers are paying for cache misses |
| Per-URL search performance | Search Console Performance export, joined to the baseline | Each mapped pair back at its own pre-launch level | Impressions recover but position stays worse: a content problem, not a redirect one |
| Event parity | GA4 realtime and DebugView, plus daily event counts | Same event names, same parameters, comparable volume | Any pre-launch event missing for 24 hours |
| Bing | Bing Webmaster Tools, URL Inspection and sitemap report | Sitemap discovered, no redirect errors on submitted URLs | A 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.