BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. data-gtm-id Codemod: 4-Command GTM Tracking Workflow

data-gtm-id Codemod: 4-Command GTM Tracking Workflow

Automate adding data-gtm-id across a Next.js app using jscodeshift; handle asChild, export CSV for GA4 reporting

14th May 2026·Updated on:15th May 2026··
Next.js
data-gtm-id Codemod: 4-Command GTM Tracking Workflow

⚡ 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.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

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.

Contents

  • Why CSS Selectors Break and Why You Should Stop Using Them for Analytics
  • Why not just use `id=""`?
  • The Naming Convention
  • The Three Scripts
  • Script 1: Audit Your Interactive Surface
  • Script 2: The Codemod
  • After the Run: Cleaning Up `unknown` IDs
  • Script 3: Export the Reference Files
  • Supporting Custom Components
  • What You Can Answer After This
  • Known Limitations
  • The Full Workflow in Order
  • Extending to Other App Areas
  • FAQ
  • Conclusion
On this page:
  • Why CSS Selectors Break and Why You Should Stop Using Them for Analytics
  • The Naming Convention
  • The Three Scripts
  • Script 1: Audit Your Interactive Surface
  • Script 2: The Codemod
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • CMS Hub
  • E-commerce Hub
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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 data-gtm-id 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:

tsx
<Button data-gtm-id="profile-button-save">Save</Button>

In GTM you now target:

css
[data-gtm-id="profile-button-save"]

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:

code
<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:

ComponentSlug
Button / buttonbutton
Link / alink
SidebarTriggersidebar-trigger
SidebarMenuButtonsidebar-menu-button
LogoutButtonlogout-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.

Some examples from a real run:

code
protected-stranke-page-link-dodaj-stranko
office-shell-sidebar-trigger-toggle-sidebar
delivery-delivery-columns-link-open-delivery-date

These are readable in a GA4 event report. You know exactly what happened and where.


The Three Scripts

The workflow uses three scripts that live in scripts/ and run via package scripts.

json
{
  "gtm:pisarna:audit":  "node scripts/audit-pisarna-interactions.cjs",
  "gtm:pisarna:dry":    "jscodeshift --parser=tsx --extensions=tsx,jsx --dry --print -t scripts/add-gtm-id.cjs \"src/app/(frontend)/[locale]/[tenant]/pisarna\" src/components/office",
  "gtm:pisarna:apply":  "jscodeshift --parser=tsx --extensions=tsx,jsx -t scripts/add-gtm-id.cjs \"src/app/(frontend)/[locale]/[tenant]/pisarna\" src/components/office",
  "gtm:pisarna:export": "node scripts/export-pisarna-gtm-ids.cjs"
}

Install the dependencies first:

bash
pnpm add -D jscodeshift transliteration

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.

js
// File: scripts/audit-pisarna-interactions.cjs

/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require("fs");
const path = require("path");

const ROOTS = [
  "src/app/(frontend)/[locale]/[tenant]/pisarna",
  "src/components/office",
];

const TARGET_PATTERNS = [
  { name: "Button",                    search: "<Button" },
  { name: "Native button",             search: "<button" },
  { name: "Next Link",                 search: "<Link" },
  { name: "Native link",               search: "<a " },
  { name: "DialogTrigger",             search: "<DialogTrigger" },
  { name: "SheetTrigger",              search: "<SheetTrigger" },
  { name: "TabsTrigger",               search: "<TabsTrigger" },
  { name: "SidebarTrigger",            search: "<SidebarTrigger" },
  { name: "SidebarMenuButton",         search: "<SidebarMenuButton" },
  { name: "SidebarMenuSubButton",      search: "<SidebarMenuSubButton" },
  { name: "LogoutButton",              search: "<LogoutButton" },
  { name: "AlertDialogTrigger",        search: "<AlertDialogTrigger" },
  { name: "SelectItem",                search: "<SelectItem" },
  { name: "DropdownMenuCheckboxItem",  search: "<DropdownMenuCheckboxItem" },
];

function walk(dirPath, files = []) {
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
  for (const entry of entries) {
    const nextPath = path.join(dirPath, entry.name);
    if (entry.isDirectory()) { walk(nextPath, files); continue; }
    if (entry.isFile() && /\.(tsx|jsx)$/.test(entry.name)) { files.push(nextPath); }
  }
  return files;
}

function getMatchesForFile(filePath) {
  const source = fs.readFileSync(filePath, "utf8");
  const lines = source.split(/\r?\n/);
  const matches = [];
  lines.forEach((line, index) => {
    for (const pattern of TARGET_PATTERNS) {
      if (line.includes(pattern.search)) {
        matches.push({ line: index + 1, pattern: pattern.name, code: line.trim() });
      }
    }
  });
  return matches;
}

function main() {
  const files = ROOTS.flatMap((rootPath) => walk(rootPath));
  const totals = new Map(TARGET_PATTERNS.map((p) => [p.name, 0]));
  const perFile = [];

  for (const filePath of files) {
    const matches = getMatchesForFile(filePath);
    if (matches.length === 0) continue;
    perFile.push({ filePath, matches });
    for (const match of matches) {
      totals.set(match.pattern, (totals.get(match.pattern) || 0) + 1);
    }
  }

  console.log("# Pisarna Interaction Audit\n");
  console.log(`Files scanned: ${files.length}`);
  console.log(`Files with matches: ${perFile.length}\n`);
  console.log("## Summary");
  for (const pattern of TARGET_PATTERNS) {
    console.log(`- ${pattern.name}: ${totals.get(pattern.name) || 0}`);
  }
  console.log("\n## Files");
  for (const entry of perFile) {
    console.log(`- ${entry.filePath}`);
    for (const match of entry.matches) {
      console.log(`  - L${match.line} ${match.pattern}: ${match.code}`);
    }
  }
}

main();

Run it with:

bash
pnpm gtm:pisarna:audit

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.

js
// File: scripts/add-gtm-id.cjs

/* eslint-disable @typescript-eslint/no-require-imports */
const path = require("path");
const { transliterate } = require("transliteration");

const TARGET_COMPONENTS = new Set([
  "button", "a", "Button", "IconButton", "Link",
  "DialogTrigger", "SheetTrigger", "TabsTrigger",
  "SidebarTrigger", "SidebarMenuButton", "SidebarMenuSubButton", "LogoutButton",
]);

const INTERACTIVE_CHILD_COMPONENTS = new Set([
  "button", "a", "Button", "IconButton", "Link", "SidebarTrigger", "LogoutButton",
]);

const TEXT_ATTRIBUTE_CANDIDATES  = ["aria-label", "title", "tooltip", "label", "placeholder"];
const VALUE_ATTRIBUTE_CANDIDATES = ["href", "value", "id", "name"];
const SELF_NAMED_FALLBACKS = new Map([
  ["SidebarTrigger", "toggle-sidebar"],
  ["LogoutButton",   "logout"],
]);

function getElementName(name) {
  if (!name) return null;
  if (name.type === "JSXIdentifier")      return name.name;
  if (name.type === "JSXMemberExpression") return getElementName(name.property);
  if (name.type === "JSXNamespacedName")  return name.name?.name ?? null;
  return null;
}

function hasAttribute(openingElement, attrName) {
  return openingElement.attributes.some(
    (attr) => attr.type === "JSXAttribute" && attr.name?.name === attrName,
  );
}

function getAttribute(openingElement, attrName) {
  return openingElement.attributes.find(
    (attr) => attr.type === "JSXAttribute" && attr.name?.name === attrName,
  ) ?? null;
}

function isTruthyAttribute(openingElement, attrName) {
  const attr = getAttribute(openingElement, attrName);
  if (!attr) return false;
  if (attr.value == null) return true;
  const value = getAttributeValue(attr);
  return value === "" || value === "true" || value === true;
}

function getAttributeValue(attr) {
  if (!attr?.value) return "";
  if (attr.value.type === "StringLiteral") return attr.value.value;
  if (attr.value.type === "Literal" && typeof attr.value.value === "string") return attr.value.value;
  if (attr.value.type === "JSXExpressionContainer") return getExpressionValue(attr.value.expression);
  return "";
}

function getExpressionValue(expression) {
  if (!expression) return "";
  switch (expression.type) {
    case "StringLiteral":  return expression.value;
    case "Literal":        return typeof expression.value === "string" ? expression.value : "";
    case "TemplateLiteral":
      if (expression.expressions.length === 0)
        return expression.quasis.map((q) => q.value.cooked ?? "").join("");
      return expression.quasis.map((q) => q.value.cooked ?? "").join(" ").trim();
    case "Identifier":         return expression.name;
    case "MemberExpression":   return flattenMemberExpression(expression);
    case "ConditionalExpression":
      return [getExpressionValue(expression.consequent), getExpressionValue(expression.alternate)]
        .filter(Boolean).join(" ");
    case "LogicalExpression":
      return [getExpressionValue(expression.left), getExpressionValue(expression.right)]
        .filter(Boolean).join(" ");
    default: return "";
  }
}

function flattenMemberExpression(expression) {
  if (!expression) return "";
  const object   = expression.object.type === "Identifier" ? expression.object.name
    : expression.object.type === "MemberExpression" ? flattenMemberExpression(expression.object) : "";
  const property = expression.property.type === "Identifier" ? expression.property.name
    : expression.property.type === "Literal" && typeof expression.property.value === "string"
      ? expression.property.value : "";
  return [object, property].filter(Boolean).join("-");
}

function slugify(value) {
  return transliterate(String(value || ""))
    .replace(/\.[^.]+$/, "")
    .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

function getFileSlug(filePath) {
  const normalizedPath = filePath.split(path.sep).join("/");
  const relative =
    normalizedPath.includes("src/app/(frontend)/[locale]/[tenant]/pisarna/")
      ? normalizedPath.split("src/app/(frontend)/[locale]/[tenant]/pisarna/")[1]
      : normalizedPath.includes("src/components/office/")
        ? normalizedPath.split("src/components/office/")[1]
        : path.basename(normalizedPath);
  return slugify(relative.replace(/\.[^.]+$/, "")) || "unknown-file";
}

function getElementSlug(elementName) {
  if (elementName === "button"  || elementName === "Button")  return "button";
  if (elementName === "a"       || elementName === "Link")    return "link";
  if (elementName === "IconButton")             return "icon-button";
  if (elementName === "SidebarTrigger")         return "sidebar-trigger";
  if (elementName === "SidebarMenuButton")      return "sidebar-menu-button";
  if (elementName === "SidebarMenuSubButton")   return "sidebar-menu-sub-button";
  if (elementName === "LogoutButton")           return "logout-button";
  return slugify(elementName);
}

function getTextFromChildren(children = []) {
  return children
    .map((child) => {
      if (child.type === "JSXText")               return child.value;
      if (child.type === "JSXExpressionContainer") return getExpressionValue(child.expression);
      if (child.type === "JSXElement")             return getTextFromChildren(child.children);
      if (child.type === "JSXFragment")            return getTextFromChildren(child.children);
      return "";
    })
    .join(" ")
    .replace(/\s+/g, " ")
    .trim();
}

function getBestValueSlug(jsxElement, openingElement, elementName) {
  const childText = getTextFromChildren(jsxElement.children);
  if (childText) return slugify(childText);

  for (const attrName of TEXT_ATTRIBUTE_CANDIDATES) {
    const value = getAttributeValue(getAttribute(openingElement, attrName));
    if (value) return slugify(value);
  }
  for (const attrName of VALUE_ATTRIBUTE_CANDIDATES) {
    const value = getAttributeValue(getAttribute(openingElement, attrName));
    if (value) return slugify(value);
  }
  if (SELF_NAMED_FALLBACKS.has(elementName)) return SELF_NAMED_FALLBACKS.get(elementName);

  const typeValue = getAttributeValue(getAttribute(openingElement, "type"));
  if (typeValue) return slugify(typeValue);

  return "unknown";
}

function getFirstInteractiveChild(jsxElement) {
  return jsxElement.children.find((child) => {
    if (child.type !== "JSXElement") return false;
    return INTERACTIVE_CHILD_COMPONENTS.has(getElementName(child.openingElement.name));
  }) ?? null;
}

module.exports = function transformer(fileInfo, api) {
  const j        = api.jscodeshift;
  const root     = j(fileInfo.source);
  const fileSlug = getFileSlug(fileInfo.path);
  const counters = new Map();

  root.find(j.JSXElement).forEach((jsxPath) => {
    const opening     = jsxPath.node.openingElement;
    const elementName = getElementName(opening.name);

    if (!TARGET_COMPONENTS.has(elementName))    return;
    if (hasAttribute(opening, "data-gtm-id"))   return;

    if (isTruthyAttribute(opening, "asChild")) {
      if (getFirstInteractiveChild(jsxPath.node)) return;
    }

    const elementSlug = getElementSlug(elementName);
    const valueSlug   = getBestValueSlug(jsxPath.node, opening, elementName) || "unknown";
    const baseId      = `${fileSlug}-${elementSlug}-${valueSlug}`;
    const count       = counters.get(baseId) || 0;
    counters.set(baseId, count + 1);
    const finalId     = count === 0 ? baseId : `${baseId}-${count + 1}`;

    opening.attributes.push(
      j.jsxAttribute(j.jsxIdentifier("data-gtm-id"), j.stringLiteral(finalId)),
    );
  });

  return root.toSource({ quote: "double", trailingComma: true });
};

module.exports.parser = "tsx";

The key design decision worth understanding is how this handles asChild. In shadcn/ui, a common pattern is:

tsx
<Button asChild>
  <Link href="/orders">View Orders</Link>
</Button>

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:

bash
pnpm gtm:pisarna:dry

When you are ready to apply:

bash
pnpm gtm:pisarna:apply

A typical first run on a medium-sized app section produces output like:

code
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

Find them all with:

bash
rg -n 'data-gtm-id="[^"]*unknown' \
  'src/app/(frontend)/[locale]/[tenant]/pisarna' \
  'src/components/office'

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:

js
// File: scripts/export-pisarna-gtm-ids.cjs

/* eslint-disable @typescript-eslint/no-require-imports */
const fs   = require("fs");
const path = require("path");

const ROOTS = [
  "src/app/(frontend)/[locale]/[tenant]/pisarna",
  "src/components/office",
];

const OUTPUT_DIR = "artifacts/gtm";
const CSV_PATH   = path.join(OUTPUT_DIR, "pisarna-gtm-ids.csv");
const TXT_PATH   = path.join(OUTPUT_DIR, "pisarna-gtm-ids.txt");

function walk(dirPath, files = []) {
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
  for (const entry of entries) {
    const nextPath = path.join(dirPath, entry.name);
    if (entry.isDirectory()) { walk(nextPath, files); continue; }
    if (entry.isFile() && /\.(tsx|jsx)$/.test(entry.name)) { files.push(nextPath); }
  }
  return files;
}

function csvEscape(value) {
  return `"${String(value ?? "").replace(/"/g, '""')}"`;
}

function extractIds(filePath) {
  const source = fs.readFileSync(filePath, "utf8");
  const lines  = source.split(/\r?\n/);
  const rows   = [];
  lines.forEach((line, index) => {
    for (const match of [...line.matchAll(/data-gtm-id="([^"]+)"/g)]) {
      rows.push({ id: match[1], file: filePath, line: index + 1, code: line.trim() });
    }
  });
  return rows;
}

function main() {
  const files = ROOTS.flatMap((r) => walk(r)).sort();
  const rows  = files.flatMap((f) => extractIds(f));
  rows.sort((a, b) => {
    if (a.id   !== b.id)   return a.id.localeCompare(b.id);
    if (a.file !== b.file) return a.file.localeCompare(b.file);
    return a.line - b.line;
  });

  fs.mkdirSync(OUTPUT_DIR, { recursive: true });

  // CSV
  const header  = ["id", "file", "line", "code"];
  const csvRows = [
    header.map(csvEscape).join(","),
    ...rows.map((r) => [r.id, r.file, r.line, r.code].map(csvEscape).join(",")),
  ].join("\n");
  fs.writeFileSync(CSV_PATH, `${csvRows}\n`, "utf8");

  // TXT — grouped by file
  const grouped = new Map();
  for (const row of rows) {
    if (!grouped.has(row.file)) grouped.set(row.file, []);
    grouped.get(row.file).push(row);
  }
  const chunks = [];
  for (const [file, fileRows] of grouped.entries()) {
    chunks.push(file);
    for (const row of fileRows) chunks.push(`  L${row.line}  ${row.id}`);
    chunks.push("");
  }
  fs.writeFileSync(TXT_PATH, `${chunks.join("\n").trim()}\n`, "utf8");

  console.log(`Exported ${rows.length} GTM IDs.`);
  console.log(`CSV: ${CSV_PATH}`);
  console.log(`TXT: ${TXT_PATH}`);
}

main();

Run it with:

bash
pnpm gtm:pisarna:export

The TXT output is grouped by file and readable in a code review or PR description:

code
src/components/office/OfficeShell.tsx
  L29  office-shell-sidebar-trigger-toggle-sidebar

src/components/office/OfficeSidebar.tsx
  L137  office-sidebar-link-pisarna
  L167  office-sidebar-link-item-title

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.

For a LogoutButton wrapper:

tsx
// File: src/modules/ecommerce/my-account/LogoutButton.tsx

interface LogoutButtonProps {
  redirectTo?: string;
  className?: string;
  variant?: ComponentProps<typeof Button>['variant'];
  'data-gtm-id'?: string;
}

export function LogoutButton({
  redirectTo = '/prijava',
  className,
  variant = 'outline',
  'data-gtm-id': dataGtmId,
}: LogoutButtonProps) {
  return (
    <Button
      data-gtm-id={dataGtmId}
      variant={variant}
      size="sm"
      className={className}
    >
      {/* ... */}
    </Button>
  );
}

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:

js
window.dataLayer.push({
  event: "ui_click",
  gtmId: "profile-button-save",
});

Or directly through gtag:

js
gtag("event", "click", {
  event_category: "ui_interaction",
  event_label: "profile-button-save",
});

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 Full Workflow in Order

bash
# 1. Audit the interactive surface
pnpm gtm:pisarna:audit

# 2. Preview proposed changes without writing
pnpm gtm:pisarna:dry

# 3. Apply the codemod
pnpm gtm:pisarna:apply

# 4. Find and fix unknown IDs
rg -n 'data-gtm-id="[^"]*unknown' \
  'src/app/(frontend)/[locale]/[tenant]/pisarna' \
  'src/components/office'

# 5. Export the reference files
pnpm gtm:pisarna:export

# 6. Verify coverage
rg -n 'data-gtm-id=' \
  'src/app/(frontend)/[locale]/[tenant]/pisarna' \
  'src/components/office'
git diff --stat

Extending to Other App Areas

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