To add Google Tag Manager to WordPress without a plugin, you inject GTM's two snippets through two hooks: the <script> snippet on wp_head (early), and the <noscript> iframe on wp_body_open (WordPress 5.2+, so it lands immediately after the opening <body> tag). Drop this into a small must-use plugin and you are done:
<?php
/**
* Plugin Name: TE Google Tag Manager
* Plugin URI: https://techearl.com/wordpress-google-tag-manager-without-plugin
* Description: Injects the Google Tag Manager container (head script + body noscript) without a third-party plugin.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-gtm
*/
if ( ! defined( 'TE_GTM_CONTAINER_ID' ) ) {
define( 'TE_GTM_CONTAINER_ID', 'GTM-XXXXXX' );
}
function te_gtm_head() {
$id = TE_GTM_CONTAINER_ID;
if ( empty( $id ) || is_admin() ) {
return;
}
?>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','<?php echo esc_js( $id ); ?>');</script>
<!-- End Google Tag Manager -->
<?php
}
add_action( 'wp_head', 'te_gtm_head', 1 );
function te_gtm_body() {
$id = TE_GTM_CONTAINER_ID;
if ( empty( $id ) || is_admin() ) {
return;
}
$src = 'https://www.googletagmanager.com/ns.html?id=' . rawurlencode( $id );
?>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="<?php echo esc_url( $src ); ?>"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<?php
}
add_action( 'wp_body_open', 'te_gtm_body' );Swap GTM-XXXXXX for your real container ID, save the file as wp-content/mu-plugins/te-google-tag-manager.php, and GTM is live. The rest of this post is why it is two snippets, why the second one is the part everyone gets wrong, and what to do on a theme that predates wp_body_open.
Why GTM is two snippets, not one
Google Tag Manager hands you two blocks of code, and they are not interchangeable.
The first is a <script> that loads gtm.js and sets up the dataLayer, the triggers, the variables, and every tag you have configured inside the container. Google's install guide says to put it "as high in the <head> as possible." That is why the snippet hooks wp_head at priority 1: the earlier GTM initializes, the less chance you miss an early pageview or a tag that needs to fire before the page finishes painting.
The second is a <noscript> containing an <iframe> that points at ns.html. This is the fallback for visitors with JavaScript disabled, and it has to live in the <body>. An iframe inside <head> is invalid HTML, which is exactly why GTM never gives you the option to put the noscript there. Google's guidance is specific: place it "immediately after the opening <body> tag."
So you have one snippet that wants to be as high in the head as possible, and one that has to be the first thing inside the body. Two different locations, two different hooks.
The body snippet is the part people get wrong
The head snippet is easy. WordPress has had wp_head since forever, every theme calls it, and hooking onto it at an early priority is routine.
The body snippet is where DIY GTM installs fall apart. For years there was no clean hook that fired right after <body>, so people did one of three messy things: pasted the noscript into functions.php and echoed it on some random late hook, jammed it into the footer, or hand-edited header.php to splice it in after the <body> tag. The footer and the random hook both put the noscript in the wrong place, and editing header.php ties your tracking to one theme.
WordPress 5.2 (May 2019) fixed this by adding the wp_body_open action. Core themes, and any theme updated since, call wp_body_open() immediately after their opening <body> tag. Hook your noscript onto that action and it renders in exactly the spot Google asks for, no theme file editing, no guessing.
Putting the noscript anywhere else is the common mistake. It does still "work" loosely (an iframe in the footer fires for no-JS visitors too), but Search Console's GTM container verification looks for the snippet right after <body>, and an out-of-place noscript can fail that check. Keep it on wp_body_open and you never think about it.
Store the container ID once
Notice the snippet defines the ID one time, as a constant, and both functions read TE_GTM_CONTAINER_ID. That is deliberate. The fastest way to ship a half-broken GTM install is to paste GTM-XXXXXX into the head block and a different (or typo'd) ID into the body block. Define it once and the two snippets can never drift apart.
I keep the literal in the plugin for a single-site setup, but the better home for it is wp-config.php, above the "stop editing" line:
define( 'TE_GTM_CONTAINER_ID', 'GTM-XXXXXX' );With the ID in wp-config.php, the plugin's own define() becomes a fallback (the if ( ! defined(...) ) guard makes sure the config value wins), staging and production can carry different container IDs, and the ID never ends up in version control with the rest of the theme. If a non-developer needs to change it, promote it to an option instead and read it with get_option( 'te_gtm_container_id' ) so it lives in the database and gets a settings field. Either way the rule holds: one source of truth, read in two places.
Themes that do not call wp_body_open
wp_body_open only fires if the theme actually calls it. Every default Twenty-* theme since Twenty Nineteen does, and most maintained commercial themes added it years ago. But an old custom theme, or one that has not been touched since before 2019, may not, and then your noscript silently never renders.
The fix is one line in the theme's header.php, right after the opening <body> tag:
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>Use the function, not a hardcoded GTM noscript. wp_body_open() is what plugins and other code expect to be present, so adding the hook (rather than the tracking code itself) fixes the noscript for GTM and for anything else that relies on the hook, and your tracking stays in the plugin where it belongs.
If you genuinely cannot edit the theme, the last resort is to hook the noscript onto a late header action and accept that it will not sit exactly after <body>. It is worse for the Search Console verification case above, but better than no noscript at all. Adding the one missing wp_body_open() line is almost always the right move.
GTM is not GA4, and you should not load both raw
A point of confusion worth clearing up: Google Tag Manager is a container, not an analytics tag. By itself it collects nothing. It is a delivery system that loads and fires whatever tags you configure inside the GTM web UI: GA4, conversion pixels, custom event scripts, and so on.
gtag.js is different. That is the direct Google tag you paste straight onto the page to send data to GA4 (or Google Ads) without a container in between. So you have two valid ways to get GA4 onto a site:
- Direct: paste the
gtag.jssnippet (or a GA4-specific helper) and you are sending GA4 hits with no GTM at all. - Through GTM: install the GTM container as above, then add a GA4 Configuration tag inside the container. GTM loads GA4 for you, and you manage every other tag from the same place.
Pick one path for GA4. If you are standing up GTM, configure GA4 as a tag inside it and do not also hardcode gtag.js on the page, or you will double-count pageviews. (If you only want GA4 and have no plans for other tags, the direct route is simpler. I cover pulling GA4's data back into WordPress in a separate piece, linked below.)
The same "pick one" rule applies to the install itself: if you add the snippets by hand as shown here, do not also run a GTM plugin that injects its own container. Two copies of the same container ID on one page is double-tagging, and it double-fires every tag. Hand-coded or plugin, never both.
Verify it actually fired
Two checks, and do both.
First, view-source on a front-end page (not the admin) and search the HTML for GTM-:
- In the
<head>, near the top, you should see theGoogle Tag Managerscript block with your real container ID in thegtm.js?id=GTM-...URL. - Right after the opening
<body>tag, you should see theGoogle Tag Manager (noscript)iframe block, also with your ID in thens.html?id=GTM-...URL.
If the head block is there but the body block is missing, your theme is not calling wp_body_open(). That is the section above.
Second, open GTM's Preview mode (the Preview button in the container's workspace). It connects Tag Assistant to your site and shows whether the container loaded and which tags fired on each interaction. View-source proves the snippet is on the page; Preview proves the container is actually talking to Google and your tags fire. A snippet that is present but shows "not connected" in Preview usually means a wrong container ID, a page cache serving old HTML (purge it), or a consent / blocker setup intercepting the request.
On consent: if you operate anywhere that requires it, GTM should run behind a Consent Mode setup so tags hold until the visitor opts in. That is its own topic and out of scope here, but build the consent layer before you start firing marketing tags in production, not after.
See also
- Clean Up wp_head in WordPress: the other side of the same hook. Before you add a script to the head, see what WordPress already prints there and what is safe to strip
- Pull GA4 Data Into WordPress With a Service Account: once GTM is loading GA4, this is how you read those analytics back into the dashboard server-side
- Lazy-Load a Google Maps Embed in WordPress: another third-party script you do not want firing on every page load, deferred the right way
- How to Optimize WooCommerce: where tag and tracking weight fits into the bigger performance picture on a store
Sources
Authoritative references this article was fact-checked against.
- Install a web container: Google Tag Manager Developer Guidedevelopers.google.com
- wp_head(): WordPress Developer Referencedeveloper.wordpress.org
- wp_body_open hook: WordPress Developer Referencedeveloper.wordpress.org





