This page teaches you how to wire Google AdSense into a Next.js 16 app the right way: the loader script, ad unit placement, and the policy and consent rules that decide whether you keep your account. It is a teaching page. This portfolio does not run ads, so nothing here is live; you are following the pattern, not copying a production config.
AdSense is display advertising. Google pays you a share of revenue when ads render and people interact with them. The hard part is not the code. The code is one script tag and a few <ins> elements. The hard part is approval and policy, and that is where most people lose the account they just earned.
How AdSense actually works
You add one loader script to your site. That script reports your domain to Google for review. Google's reviewers and automated systems decide whether your site qualifies. If approved, you create ad units in the AdSense dashboard, and each unit gives you a small block of markup you drop into a page. The loader script finds those blocks and fills them with ads.
Two identifiers matter:
- Your publisher ID, shaped like
ca-pub-0000000000000000. It identifies your account and goes in the loader URL. - Each ad slot ID, a numeric string tied to a single ad unit you created in the dashboard.
Keep both out of your code as hardcoded magic strings. Put the publisher ID in an environment variable so you can swap accounts and so the value is obvious in review.
Are you AdSense-ready?
Most rejections are decided before you write a line of ad code. These are the readiness rules, the things Google checks for, framed so you can hand them to an AI as a pre-flight checklist and as the implementation pattern once you are approved.
Whenyou are about to apply for AdSense
Doship the required pages first: a published privacy policy, an about page, and a reachable contact method. A site with no privacy policy is an automatic rejection.
// These must exist and be linked from the footer before you apply.
// /legal/privacy /about /contact (see the Legal Pages doc)Whenyou want the crawl surface to read as content-dense (a quality signal)
Dokeep auth shells and thin pages out of the index. noindex /login and /signup and exclude them from the robots allow-list so the crawlable surface is mostly real content.
export const metadata = { robots: { index: false, follow: false } };
// robots.ts allow-list: only content routes, never /login or /signup.Whenyou add the AdSense account association
Dodeclare the publisher ID with the google-adsense-account meta in your root metadata. This links the domain to your account for review, separate from the loader script.
export const metadata: Metadata = {
other: { "google-adsense-account": "ca-pub-0000000000000000" },
};Whenyou load the AdSense script
Doload it once in the root layout with next/script at afterInteractive, gated behind an env flag. The same single script also delivers Google's certified CMP consent banner to EEA/UK/Swiss visitors; there is no separate consent tag to embed.
{adsEnabled ? (
<Script id="adsbygoogle-init" strategy="afterInteractive" crossOrigin="anonymous"
src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${ADSENSE_CLIENT}`} />
) : null}Whenyou define ad slots before the units exist in the dashboard
Dokeep slot IDs empty until you create the real unit, and make the ad component render nothing for an empty slot. A misconfigured deploy should show no ad, never a broken or blank unit.
export const AD_SLOTS = { dashboard: "", library: "" } as const; // fill when real
export const hasAdSlot = (n: keyof typeof AD_SLOTS) => AD_SLOTS[n].trim().length > 0;
// <AdSlot> returns null when !hasAdSlot(name).Whensome users pay and some do not
Doshow ads to free and anonymous visitors only; never render an ad unit for a paid user. Gate it in one viewer component so no ad can leak onto a Pro page.
// Paid users get an ad-free experience as a perk; the gate is here, in one place.
if (viewer.isPaid) return null;
return <AdSlot name={name} />;Get an account, and be honest about approval
Sign up at the AdSense site with the Google account you want to receive payments on. You submit your domain and add the loader snippet (the next step). Then you wait.
Approval is gated entirely by Google. There is no API, no Next.js trick, and no header you can set that speeds it up or guarantees it. Reviews commonly take a few days to a few weeks. Common rejection reasons are real and worth internalizing before you apply:
- Thin content. A handful of short pages, or pages that are mostly navigation, get rejected as "low value."
- Under construction. Placeholder pages, empty sections, or "coming soon" blocks signal an unfinished site.
- Missing required pages. No privacy policy, no way to contact you, no about page.
- Policy-violating content. Anything Google's content policies prohibit.
Treat approval as a content milestone, not a code milestone. If your site is not genuinely useful to a stranger, fix that first. Applying repeatedly to a thin site wastes weeks and can flag the account.
Load the AdSense script with next/script
AdSense ships a single loader script. Do not drop a raw <script> tag in your HTML. Use next/script, which controls when and how the script loads relative to hydration. Per the Next.js docs, the default strategy is afterInteractive, which loads the script client-side after some hydration occurs. That is the correct strategy for an ad or analytics style tag: it should load early but never before your own first-party code.
Add the loader once, in the root layout, so it is available to every page. The layout stays a Server Component; next/script works fine there because its rendering is handled by the framework.
import Script from "next/script";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Your Site",
};
const ADSENSE_CLIENT = process.env.NEXT_PUBLIC_ADSENSE_CLIENT;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
{ADSENSE_CLIENT ? (
<Script
id="adsense-loader"
strategy="afterInteractive"
crossOrigin="anonymous"
src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${ADSENSE_CLIENT}`}
/>
) : null}
</body>
</html>
);
}Set the client ID in your environment, not in the source:
NEXT_PUBLIC_ADSENSE_CLIENT=ca-pub-0000000000000000A few details that matter:
- The
idprop gives the script a stable identity so Next.js does not inject it twice across navigations. crossOrigin="anonymous"matches the attribute AdSense expects on the loader.- The
NEXT_PUBLIC_prefix is required because the value is used in the browser. That is fine: a publisher ID is public by design. Never apply that prefix to anything secret. - Guarding on the env var means a missing config renders nothing instead of a broken script tag, so local and preview builds stay ad-free.
Place an ad unit
In the AdSense dashboard you create an ad unit and get back a slot ID plus a snippet. The snippet is an <ins class="adsbygoogle"> element followed by a tiny script that pushes one entry onto the adsbygoogle array. That push tells the loader "fill this slot."
The push call touches window, so it has to run in the browser. Wrap it in a small Client Component. This is exactly the case the global rule carves out: "use client" only where you need browser APIs or interactivity.
"use client";
import { useEffect } from "react";
type AdUnitProps = {
slot: string;
};
declare global {
interface Window {
adsbygoogle?: unknown[];
}
}
export function AdUnit({ slot }: AdUnitProps) {
useEffect(() => {
try {
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch {
// The loader has not arrived yet, or an ad blocker removed it.
// Failing quietly here is correct: a missing ad must never break the page.
}
}, []);
return (
<ins
className="adsbygoogle"
style={{ display: "block", minHeight: 280 }}
data-ad-client={process.env.NEXT_PUBLIC_ADSENSE_CLIENT}
data-ad-slot={slot}
data-ad-format="auto"
data-full-width-responsive="true"
/>
);
}The minHeight reserves vertical space before the ad fills, so the fill does not shove your content down and tank your layout shift score.
Now drop the unit into a real content page, between substantial blocks of your own content, never as the whole page:
import { AdUnit } from "@/app/_components/ad-unit";
import { getArticle } from "@/lib/articles";
export default async function Post({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await getArticle(slug);
return (
<article>
<h1>{article.title}</h1>
<section>{article.intro}</section>
<AdUnit slot="1234567890" />
<section>{article.body}</section>
</article>
);
}Note that params is awaited. In Next.js 16 params is a Promise, so the page is async and you await params before reading slug. That is unrelated to ads, but it is the kind of detail that silently breaks a copied snippet.
Handle consent before the ads load
If any of your traffic is from the EU, the UK, or California, you have legal duties around tracking. AdSense personalizes ads using cookies, which means you generally need consent before those cookies are set. Google requires a compliant consent mechanism (a CMP) for traffic in regulated regions, and it can withhold revenue if you ignore this.
The clean pattern is to load a consent manager before the AdSense loader, decide based on the visitor's choice, and only then allow personalized ads. The Next.js docs call out exactly this case: cookie consent managers are a textbook use of the beforeInteractive strategy, because they must run before other scripts. Scripts with beforeInteractive are injected into the document head and execute before any first-party code, regardless of where you place the tag.
import Script from "next/script";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{/* Consent manager runs before any ad or analytics script. */}
<Script
id="consent-cmp"
strategy="beforeInteractive"
src="https://your-cmp.example.com/cmp.js"
/>
{children}
</body>
</html>
);
}The AdSense loader from the earlier step stays at afterInteractive. Ordering by strategy, not by source position, is the point: the CMP runs first, the ad loader runs after hydration begins, and personalized ads only fire once consent is in place. If the visitor rejects, your CMP withholds the consent signal and AdSense serves non-personalized or no ads accordingly.
Rules that keep your account
Place ad units inside real, finished content, with clearly more of your own material than ads on the page. Ads should sit beside value you created, never stand in for it.
Do not put ads on thin, empty, placeholder, or "under construction" pages, and never click your own ads or ask anyone else to. Both are direct policy violations that get accounts banned, not warned.
Avoid
// A near-empty page that exists only to host ads.
export default function Page() {
return (
<main>
<h1>Welcome</h1>
<AdUnit slot="1111111111" />
<AdUnit slot="2222222222" />
<AdUnit slot="3333333333" />
</main>
);
}Prefer
// Substantial content first; a single unit set within it.
export default function Page() {
return (
<main>
<h1>How to pass the exam</h1>
<section>{/* hundreds of words of real guidance */}</section>
<AdUnit slot="1111111111" />
<section>{/* more real content */}</section>
</main>
);
}