---
title: "data-gtm-id Codemod: 4-Command GTM Tracking Workflow"
slug: "data-gtm-id-codemod-gtm-tracking-workflow"
published: "2026-05-14"
updated: "2026-05-15"
categories:
  - "Next.js"
tags:
  - "data-gtm-id codemod"
  - "data-gtm-id"
  - "jscodeshift"
  - "Next.js"
  - "shadcn/ui"
  - "asChild"
  - "Google Tag Manager"
  - "GA4"
  - "codemod workflow"
  - "transliteration"
  - "analytics tracking"
  - "audit interactive surface"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "data-gtm-id codemod automates adding stable GTM IDs across Next.js apps with jscodeshift — audit, apply, fix unknowns, and export CSV for GA4. Try it now."
llm-prereqs:
  - "Next.js"
  - "jscodeshift"
  - "Node.js"
  - "pnpm"
  - "shadcn/ui"
  - "transliteration"
  - "Google Tag Manager"
  - "GA4"
---

**Summary Triples**
- (data-gtm-id Codemod: 4-Command GTM Tracking Workflow, expresses-intent, how-to)
- (data-gtm-id Codemod: 4-Command GTM Tracking Workflow, covers-topic, data-gtm-id codemod)
- (data-gtm-id Codemod: 4-Command GTM Tracking Workflow, provides-guidance-for, data-gtm-id codemod automates adding stable GTM IDs across Next.js apps with jscodeshift — audit, apply, fix unknowns, and export CSV for GA4. Try it now.)

### {GOAL}
data-gtm-id codemod automates adding stable GTM IDs across Next.js apps with jscodeshift — audit, apply, fix unknowns, and export CSV for GA4. Try it now.

### {PREREQS}
- Next.js
- jscodeshift
- Node.js
- pnpm
- shadcn/ui
- transliteration
- Google Tag Manager
- GA4

### {STEPS}
1. Audit interactive surface
2. Run dry codemod preview
3. Apply codemod changes
4. Resolve unknown data-gtm-id values
5. Export GTM ID reference files
6. Verify and extend coverage

<!-- llm:goal="data-gtm-id codemod automates adding stable GTM IDs across Next.js apps with jscodeshift — audit, apply, fix unknowns, and export CSV for GA4. Try it now." -->
<!-- llm:prereq="Next.js" -->
<!-- llm:prereq="jscodeshift" -->
<!-- llm:prereq="Node.js" -->
<!-- llm:prereq="pnpm" -->
<!-- llm:prereq="shadcn/ui" -->
<!-- llm:prereq="transliteration" -->
<!-- llm:prereq="Google Tag Manager" -->
<!-- llm:prereq="GA4" -->

# data-gtm-id Codemod: 4-Command GTM Tracking Workflow
> data-gtm-id codemod automates adding stable GTM IDs across Next.js apps with jscodeshift — audit, apply, fix unknowns, and export CSV for GA4. Try it now.
Matija Žiberna · 2026-05-14

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:

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

Some examples from a real run:

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

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

```
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