You ship one page. Google can crawl it at four or five different URLs, and it treats every one of them as a separate document until you say otherwise. On a Next.js site deployed to Vercel, most of those duplicates are created by defaults you never chose, and they quietly split your rankings across addresses that should have been one.
This is the cleanup pass I do on every service business site before touching content. It takes about an hour and it is almost entirely config.
Where the copies come from
Take a single page, /services/water-heater-repair. Here are the URLs a crawler can end up fetching for it:
https://example.com/services/water-heater-repairhttps://www.example.com/services/water-heater-repair-
https://example.com/services/water-heater-repair/(trailing slash) -
https://example.com/services/water-heater-repair?utm_source=gbp(any query string) -
https://my-site-git-main-team.vercel.app/services/water-heater-repair(preview and branch deployments)
Each one returns 200 with the same HTML. Each one can collect its own inbound links. The Google Business Profile link uses the UTM version, the footer uses the trailing slash version, a partner links to www, and now the page's authority is spread over four addresses. Google will usually pick one as the canonical, but "usually" is not a strategy, and the pick is not always the one you want.
The fix is four small decisions, each enforced in code.
Decision 1: one host, one scheme
Pick apex or www and redirect the other with a permanent redirect. On Vercel the clean way is in the project's Domains settings: add both, mark one as a redirect to the other, and Vercel answers with a 308 at the edge before your app runs. HTTP to HTTPS is handled there too.
If you cannot control the domain layer, do it in middleware so it is still a single redirect hop:
// middleware.ts (proxy.ts on Next.js 16 and later) import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(req: NextRequest) { const host = req.headers.get('host') ?? ''; if (host.startsWith('www.')) { const url = new URL(req.url); url.hostname = host.replace(/^www\./, ''); return NextResponse.redirect(url, 308); } return NextResponse.next(); } export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'], }; One hop matters. A chain of www to apex to trailing slash to final page wastes crawl budget and leaks a little signal at every step.
Decision 2: pick a trailing slash policy and let Next.js enforce it
Next.js has an opinion here and will redirect for you, but only if you set it:
// next.config.js module.exports = { trailingSlash: false, }; With trailingSlash: false, a request to /services/ gets a 308 to /services. With true, it goes the other way. Either choice is fine. The mistake is leaving it unset and then linking to both forms from inside your own site, which is what happens when a CMS stores slugs one way and a template writes them another. This is also where your internal links start pointing at mixed versions, and the internal link graph Google actually builds from your site gets fragmented before any external link shows up.
Decision 3: declare the canonical on every page, from the data
A canonical tag tells Google which URL is the real one when it finds a duplicate. Query strings are the big reason you need it: you cannot redirect away ?utm_source=gbp without breaking attribution, so you keep the parameter and point the canonical at the clean path.
In the App Router this is two pieces. Set the base once in the root layout:
// app/layout.tsx import type { Metadata } from 'next'; export const metadata: Metadata = { metadataBase: new URL('https://example.com'), }; Then set the canonical per route from the same slug the page renders from. Do not hardcode it, and do not put a canonical in the root layout, because every page would inherit the homepage as its canonical:
// app/services/[slug]/page.tsx import type { Metadata } from 'next'; type Props = { params: Promise<{ slug: string }> }; export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params; return { alternates: { canonical: `/services/${slug}` }, }; } Next.js resolves the relative path against metadataBase and writes <link rel="canonical" href="https://example.com/services/water-heater-repair">. The query string is never part of it, which is the whole point. If the slug comes from Sanity or another CMS, this is one query away from being correct on every page forever.
A sanity check I run after this ships: open five pages with junk parameters on the end and view source. Every one should show a canonical with no ? in it.
Decision 4: keep preview deployments out of the index
Vercel adds an X-Robots-Tag: noindex header to preview deployments automatically, so the *.vercel.app URLs for branches and pull requests do not get indexed on their own. The gap is anything that looks like production but is not: a staging custom domain, a client review domain, or a branch you aliased to something memorable. Those get crawled like any other site.
Make the robots file follow the environment instead of the code:
// app/robots.ts import type { MetadataRoute } from 'next'; export default function robots(): MetadataRoute.Robots { const isProduction = process.env.VERCEL_ENV === 'production'; if (!isProduction) { return { rules: { userAgent: '*', disallow: '/' } }; } return { rules: { userAgent: '*', allow: '/' }, sitemap: 'https://example.com/sitemap.xml', }; } A disallow in robots.txt keeps crawlers from fetching pages, but a URL that is already linked somewhere can still appear in results without content. For a staging domain that has ever been public, add a noindex header in middleware under the same VERCEL_ENV check so the pages drop out instead of lingering.
How to confirm it worked
Three checks, in order of effort.
First, hit the variants from a terminal and read the status codes. You want a single 308 to the canonical form, then a 200:
for u in \ "http://example.com/services" \ "https://www.example.com/services" \ "https://example.com/services/" \ "https://example.com/services?utm_source=test" do echo "== $u" curl -sIL "$u" | grep -iE '^(HTTP|location)' done Second, in Google Search Console, run URL Inspection on a page and compare "User-declared canonical" with "Google-selected canonical". When they match on your five most important pages, Google agrees with your config. When they do not, Google is seeing a signal you are not, usually a flood of internal links to the other version.
Third, search site:example.com and look for the same title twice. Duplicates in that list are the ones still leaking.
None of this makes a page rank. It makes sure the page you built is the only page competing for the spot, which is the first thing we fix in the SEO work we do for service business sites, because content effort spent on a page with three doubles is effort divided by four.
Written by the team at Level Up Digital Marketing Group, a San Diego agency that builds and ranks service business websites on Next.js and Vercel.