Automate adding data-gtm-id across a Next.js app using jscodeshift; handle asChild, export CSV for GA4 reporting
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
You just finished building the app. The UI works, the flows are solid, and you are ready to open it to real users. Then the product question arrives: "Can we track which buttons people are clicking?"
The answer is yes — but if you reach for Google Tag Manager and start writing CSS selectors against your component classes, you are building on sand. Class names change. Layouts get refactored. Someone cleans up unused styles without thinking about analytics, and your tracking silently breaks with no warning.
The stable approach is to add a dedicated data-gtm-id attribute to every meaningful interactive element in your codebase. GTM targets those attributes instead of fragile DOM paths. The attribute is explicit, human-readable, independent of styling, and survives refactors cleanly.
The problem is doing this across hundreds of files. You are not going to do it by hand. This guide walks through a repeatable codemod workflow that audits your interactive surface, adds automatically using a proper AST transform, and exports a reference CSV and TXT file for your analytics team — all in four commands.
I built this workflow for a production Next.js app with shadcn/ui components, Slovenian text labels, and asChild composition throughout. The scripts are included in full.
Why CSS Selectors Break and Why You Should Stop Using Them for Analytics
When a GTM setup starts with selectors like .btn-primary or div > main > section > button:nth-child(2), it feels fine on day one. By week three of active development it has already broken at least once, silently, because:
a component variant changed and the class name changed with it
a layout was refactored and the DOM structure shifted
asChild composition changed which element actually renders in the DOM
someone cleaned up styles without knowing GTM depended on them
The core issue is that CSS selectors describe appearance and structure, not intent. Analytics needs to track intent — the user clicked the Save button on the Profile page — and appearance is the wrong layer for that.
A data-gtm-id attribute describes intent directly:
That selector will not break when the button gets a new variant class, moves inside a different layout wrapper, or gets refactored into a separate component. The attribute travels with the element through every refactor.
Why not just use id=""?
A normal HTML id attribute is already doing several jobs: CSS targeting, accessibility references, browser anchor links, third-party script hooks. Adding analytics intent on top of all that creates coupling that breaks in non-obvious ways. A custom data attribute is explicitly for tracking and does not interfere with any of those concerns.
The Naming Convention
Every data-gtm-id value follows this pattern:
<file-slug>-<element-slug>-<value-slug>
The file slug comes from the file path, not just the filename. This matters in Next.js because half your files are named page.tsx. A bare page-button-save is meaningless. A protected-stranke-page-button-save is unambiguous.
The element slug normalizes component names to something readable:
Component
Slug
Button / button
button
Link / a
link
SidebarTrigger
sidebar-trigger
SidebarMenuButton
sidebar-menu-button
LogoutButton
logout-button
The value slug is derived in order of preference: visible child text, aria-label, title, tooltip, label, placeholder, href, value, id, name. If none of those yield a useful string, the codemod writes unknown and you fix it manually afterward.
jscodeshift is the AST codemod toolkit. transliteration handles non-ASCII characters in labels — so Sveža zaloga becomes sveza-zaloga instead of producing garbage in the attribute value.
Script 1: Audit Your Interactive Surface
Before touching any code, run the audit. This scans your target directories and prints every interactive element with its file path and line number.
This gives you a realistic count of the surface area before you run the codemod. If the numbers look much larger or smaller than expected, it is worth investigating before proceeding.
Script 2: The Codemod
This is the core transform. It uses jscodeshift to parse your TSX and JSX files as an AST, find target interactive elements, derive the data-gtm-id value from file path and element context, and add the attribute. Elements that already have data-gtm-id are skipped.
Here Button is a wrapper that renders Link as the actual DOM element. If the codemod adds data-gtm-id to the outer Button, the attribute may not reach the rendered DOM node. So the codemod detects asChild and checks whether an interactive child exists. If it does, it skips the wrapper and lets the child get tagged on its own pass.
Run a dry preview first — this prints the proposed changes without writing any files:
pnpm gtm:pisarna:dry
When you are ready to apply:
pnpm gtm:pisarna:apply
A typical first run on a medium-sized app section produces output like:
86 ok
20 unmodified
0 errors
unmodified means those files already had data-gtm-id on every target element. ok means the transform ran and wrote changes.
After the Run: Cleaning Up unknown IDs
Some elements will get data-gtm-id="...-unknown" because their label is not statically inferable. Common cases:
icon-only buttons with no text or aria-label
dynamic labels built at runtime: <Button>{isSaving ? "Saving..." : "Save"}</Button>
date picker triggers
delete buttons in table rows where the row context is dynamic
For each one, either add an aria-label to the element (which improves accessibility as a side effect) or rename the data-gtm-id manually to something meaningful. Destructive actions like delete and confirm dialogs are worth naming carefully since they are often the highest-value events for analytics.
Script 3: Export the Reference Files
After the codemod and manual cleanup, export a reference artifact for your analytics team:
The CSV is for your analytics team — they can filter by ID prefix, sort by file, and build out their GTM trigger configuration from it without digging through source code.
Supporting Custom Components
If you have a custom component that wraps a primitive but does not forward arbitrary props, the data-gtm-id will not reach the DOM. You need to add explicit support for it.
The pattern is: accept 'data-gtm-id'?: string in the interface, destructure it, and forward it to the inner primitive. Any component in your TARGET_COMPONENTS set that is itself a wrapper needs this treatment, otherwise the codemod adds the attribute correctly but it never reaches a real DOM node.
What You Can Answer After This
With stable IDs in place, GTM and GA4 can answer questions that were previously unreliable or impossible with CSS selectors:
How many users clicked Save on the Profile page?
How many users opened the Delete dialog versus confirmed the deletion?
Which CTA placement converts better?
Where do users drop off in the checkout flow?
Is the sidebar navigation being used?
The event payload through dataLayer is straightforward:
GTM reads the data-gtm-id from the clicked element and passes it through. The naming convention makes these event labels self-documenting in your GA4 reports.
Known Limitations
Dynamic labels. If a button's text is built at runtime, the codemod produces the best static approximation it can. {isSaving ? "Saving..." : "Save"} will generate a value slug from both branches joined together. Review those manually and name them explicitly.
Very generic value slugs. Sometimes the best derivable value is still weak — title, label, row-original-name. These are better than CSS selectors, but high-value conversion events should get specific names that will read clearly in a report.
SelectItem and DropdownMenuCheckboxItem. The codemod intentionally skips mass-tagging every option in a select or dropdown. Per-option analytics adds noise and maintenance overhead for low-signal data. Add those selectively when there is a concrete reporting need.
The scripts use a ROOTS array to define which directories to scan and transform. To apply this workflow to a different section of the app — storefront, admin, checkout — copy the three scripts or make them parameterized, update ROOTS, and run the same four steps. The naming convention handles new file slugs automatically since it derives them from the path.
FAQ
Does adding data-gtm-id affect performance?
No. Data attributes are inert HTML. They add a small amount to the serialized HTML payload but have no runtime cost.
What if two elements in the same file get the same derived ID?
The codemod tracks a counter per base ID within each file. The first element gets profile-button-save, the second gets profile-button-save-2. Review duplicates after the run — they often indicate elements that need distinct manual names.
Should I add data-gtm-id to every interactive element?
No. Start with high-value actions: create, save, submit, cancel, delete, confirm, duplicate, preview, and primary navigation. Low-signal interactions like every SelectItem option add noise.
The codemod ran but I see 0 ok and all unmodified. What happened?
The files were already tagged from a previous run. That is the expected output. If you are applying to a new area for the first time and see this, check that your ROOTS paths match the actual directory structure.
Can I use this with Remix, Vite, or non-Next.js React apps?
Yes. The codemod works on any JSX/TSX files. The only Next.js-specific part is the file slug logic that strips the route prefix from the path. Update getFileSlug to strip whatever prefix makes sense for your project structure.
Conclusion
CSS selectors are the wrong layer for analytics. They describe appearance, and appearance changes constantly in active development. By running this codemod workflow, you get a stable analytics contract that is independent of styling, survives refactors, and gives your GTM and GA4 setup something to rely on.
The workflow takes about an hour for a new app section — audit, codemod, unknown cleanup, export — and produces a reference artifact your analytics team can work from immediately. After that, new components follow the same convention and the whole system stays coherent.
If you have questions about adapting the scripts to your project structure or handling specific component patterns, leave a comment below. And if you found this useful, subscribe for more practical guides on building production Next.js apps.
Thanks,
Matija
I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.