Google Publisher Tag Ads in Single Page Application (Next.js) [CASE STUDY]

In one of the projects I worked in, ads were the only source of income for the client, so they had to be implemented properly. Ad agency hired by client used Google Publisher Tag (GPT) ads, which is a popular choice.

At first, it seemed like a quite simple task — the agency provided all pieces of code that just had to be pasted in the website. It turned out that these codes were good for a simple static website, but to make it work properly in SPA (we’re talking Next.js app here) it required some customization.

The topic is not documented that well, so I had to do a lot of experiments and email exchanges with the ad agency to make it work smoothly. In this article, I’ll present my approach to this problem.

How the ads work?

<!--
First two scripts are simple:
load GPT library and initialize googletag and the command queue.
They can be placed in the site's <head>
-->
<script
async="async"
src="https://securepubads.g.doubleclick.net/tag/js/gpt.js"
></script>
<script>
var googletag = googletag || {};
googletag.cmd = googletag.cmd || [];
</script>
<!--
This script creates "ad unit" that will be then displayed on the page
-->
<script>
googletag.cmd.push(function () {
/**
Mapping assigns sizes of an ad unit to corresponding breakpoints,
it makes the ad responsive
*/
var mapping = googletag
.sizeMapping()
.addSize([1100, 0], [[750, 200]])
.addSize([960, 0], [[468, 60]])
.addSize([0, 0], [])
.build();
/**
Now the slot is being defined.
The function accepts three arguments:
- id of the slot (set by the agency in their ad management console),
- array of sizes (also set by them),
- id of the div in which the ad should be displayed
*/
googletag
.defineSlot(
"/52555387/XYZ.pl_750x200_7_a_d",
[
[750, 200],
[468, 60],
],
"div-gpt-ad-XYZ.pl_750x200_7_a_d"
)
.defineSizeMapping(mapping)
.addService(googletag.pubads());
googletag.enableServices();
});
</script>
<!--
The part below should be placed in the body of the page
It is a div with id the same as in "defineSlot()" function
and a script that displays a defined slot inside that div
-->
<div id="div-gpt-ad-XYZ.pl_750x200_7_a_d">
<script>
googletag.cmd.push(function () {
googletag.display("div-gpt-ad-XYZ.pl_750x200_7_a_d");
});
</script>
</div>
</script></div>

So to wrap it up — what happens when you create a page with a code like above?

  1. GPT script is loaded.
  2. Ad slot with a specific ID is defined with its size and a target div’s ID. It then stays in the memory.
  3. googletag.display()function is executed that makes a request for the specific ad slot.
  4. HTML fetched in the request above is being placed inside the div. It is usually an<iframe>that displays the ad.

The most important things here:

  • Defined ad slot stays in the memory until page reload.
  • An ad with a specific ID can only be displayed once on the page — trying to rungoogletag.display()on an already displayed ID will not work.

The problem

The app is written in Next.js (Server-Side Rendering), so on the initial load we get an already rendered HTML with all the ad codes and everything works the same as if it was a simple static page. But when we navigate on the page it isn’t reloaded — there is only hydration. So there are two problems when navigating between pages:

  1. googletag.display() is not executed so new ads are not fetched.

Solution

The desired behavior is:

  • when the ad component is rendered, it defines the ad slot and displays it,
  • when route is changed, all defined slots are removed from the memory,
  • after going to a different page, ad components on that page define new slots and display them.

As you can see in the example ad code, the only things that differentiate the slots are: ID, sizes, and mapping. So it’s natural to put this data in a separate file:

const.js
const ads = {
"750x200_7_a_d": {
sizes: [[750, 200],[468, 60]],
mapping: {
0: [],
960: [468, 60],
1100: [750, 200],
}
},
...
}

The whole functionality of defining the ad slot and displaying it can be put inside a hook so that the code can be reused if we would like to have different components that display the ad:

useAdSlot.js
import { useEffect } from "react";
export function useAdSlot({ mapping, sizes, id, isTransitioning }) {
useEffect(() => {
if (!isTransitioning && typeof window !== undefined) {
const { googletag } = window;
googletag.cmd.push(function () {
const adMapping = googletag.sizeMapping();
Object.keys(mapping).forEach((breakpoint) => {
adMapping.addSize([Number(breakpoint), 0], [mapping[breakpoint]]);
});
const builtMapping = adMapping.build();
googletag
.defineSlot(
`/52555387/XYZ.pl_${id}`,
sizes,
`div-gpt-ad-XYZ.pl_${id}`
)
.defineSizeMapping(builtMapping)
.addService(googletag.pubads());
googletag.enableServices();
});
googletag.cmd.push(function () {
googletag.display(`div-gpt-ad-XYZ.pl_${id}`);
});
}
}, [mapping, sizes, id, isTransitioning]);
}

The hook can be used in Ad component that takes the adId as a prop and displays an empty div that will be then filled with actual ad:

Ad.js
import React from "react";
import { useTransitionState } from "@/containers/TransitionState";
import { useAdSlot } from "@/hooks/useAds";
import { ads } from "./const";
function Ad({ adId }) {
const { isTransitioning } = useTransitionState();
const ad = ads[adId];
useAdSlot({
mapping: ad.mapping,
sizes: ad.sizes,
id: adId,
isTransitioning,
});
return <div id={`div-gpt-ad-XYZ.pl_${adId}`} />;
}
export default Ad;

Note the isTransitioningargument passed to the hook above. Basically, we want to be sure that we run the GPT functions every time we change the page. There are different ways of doing that, but in the project it worked best with the transition state that was already in the app (the state was for displaying a loader when the user is navigating between pages - it was implemented with next.js router events).

Ok, so the first problem is solved — the GPT functions that display the ads run on every page change. But there is still an issue with old ad slots that stay in the memory. Here is where the destroySlots() function comes to the rescue. When called without arguments it removes all data related to displayed ads from the internal GPT state. When to run it? Ideally — after we redirect to another page but before other Ad components start displaying more ad units. And here next.js routing and its events come in handy again. We already manage the transition state in the main next.js App component, so we can just add destroySlots() function there.

Below, I’m showing a piece of code from our main App component. You can see there the methods that manage the transition state (with destroySlots() added) and how it is connected to next.js router events. The value of isTransitioning is then passed to Ad components via context (note that our App was a class component, but it could be easily recreated if yours is functional).

setTransitionStarted = () => {
this.setState({ isTransitioning: true });
// destroy all ad slots
const { googletag } = window;
googletag.cmd.push(function () {
googletag.destroySlots();
});
};
setTransitionComplete = () => {
this.setState({ isTransitioning: false });
};
componentDidMount() {
Router.events.on("routeChangeStart", this.setTransitionStarted);
Router.events.on("routeChangeComplete", this.setTransitionComplete);
}
componentWillUnmount() {
Router.events.off("routeChangeStart", this.setTransitionStarted);
Router.events.off("routeChangeComplete", this.setTransitionComplete);
}

Now the whole ad lifecycle works as desired. The ad slots are defined and displayed after every page transition and destroyed before going to another page.

Conclusion

Originally published at https://www.monterail.com.

A close-knit team of 110+ experts offering Web & mobile development for startups and businesses.