Track custom events without Google Tag Manager
How to track signups, clicks and downloads with a few lines of JavaScript instead of a tag manager, plus real examples.
How to track signups, clicks and downloads with a few lines of JavaScript instead of a tag manager, plus real examples.
Google Tag Manager exists to solve a real problem: letting marketing teams add and change tags without shipping code. If you are the person who ships the code, or if you use a small analytics tool with a simple event API, a tag manager is often pure overhead. It adds a container script to every page, a second layer of configuration to debug, and a set of triggers and variables that nobody remembers six months later.
If all you need is to know when someone signs up, clicks a button or downloads a file, you can usually do it with a few lines of JavaScript in the place where the action already happens. This guide shows the pattern using a simple global event helper, which most lightweight, privacy-first analytics tools expose.
A tag manager is a shared abstraction for people who cannot edit the site. On a small team, that abstraction costs more than it saves: another script to load, another interface to learn, and one more place where analytics can break without the codebase showing it. Custom events sent directly from your own code are easier to review, easier to test and live next to the feature they describe.
Most lightweight tools expose a global function — often called something like lumen or tracker — that takes an event name and an optional object of properties. Call it when the action completes, not when the page loads.
// Send a signup event with a few useful properties.
if (typeof window !== "undefined" && window.lumen) {
window.lumen("Signup", {
plan: "pro",
source: "pricing-page",
});
}The guard matters: the script may not have loaded yet, and it can be blocked by an ad blocker. Wrapping the call means a missing tracker never throws and never breaks your signup flow. Analytics should always fail silently.
For button clicks and downloads, attach the event to the handler you already have. There is no need for a global click listener or a data attribute convention unless you want one.
document.querySelector("#download-report").addEventListener("click", () => {
if (window.lumen) {
window.lumen("Download", { file: "pricing-guide.pdf" });
}
});For outbound links, you can either add a handler to the specific links you care about, or use one delegated listener on the document if you have many. Prefer the specific approach where you can: it is obvious what is tracked, and it does not fire on links you never intended to measure.
If a marketing team needs to add third-party tags — ad pixels, affiliate scripts, A/B tools — without engineering involvement, a tag manager is the right tool. The mistake is using one purely to send your own analytics events when you already control the code. Use the manager for what it is good at, and send your product and marketing events directly.
The result is fewer moving parts and events that live beside the feature they measure. When a signup flow changes, the event changes in the same pull request, and you can see in review exactly what will be sent.
The hard part of custom events is not sending them; it is keeping them understandable a year later. Decide on a short list of event names up front and treat them as an API. Verb-based names like Signup, Purchase, Download and Click are easy to read and hard to misapply. Put the variable detail in properties, and keep property values low-cardinality so a report does not fill with one-off strings. A plan with a dozen event names and a handful of properties will outlast a sprawling taxonomy that nobody can remember. Write the list down, next to the code that sends each one, and review it when a feature changes.
Avoid encoding meaning into the event name that belongs in a property. Signup-Free, Signup-Pro and Signup-Studio are three events that are hard to combine; Signup with a plan property is one event that answers more questions. The same applies to page paths and referrers: capture the raw value and let the dashboard group it, rather than inventing your own categories in JavaScript. Consistency matters more than cleverness, because the value of an event comes from comparing it over time. A stable name used for years is worth more than a perfect name used for a month.
Send each event in development with the debug flag on and watch the console to confirm the name and properties are what you expect. Then check the real-time view after deploying a small change. It is easy to send the wrong property, fire on click instead of on success, or forget to guard against a missing tracker. A five-minute check per event prevents months of quietly wrong data. If you cannot see the event, assume it is not firing rather than assuming the dashboard is broken, and work backwards from the network request. Turn on the debug flag, repeat the action, and inspect what is actually sent before you change the code. Most missing events are caused by a handler that fires on the wrong element, a guard that returns early, or a tracker that has not loaded yet. None of those are visible from the dashboard alone, which is why the console check matters. Once an event is confirmed, add a tiny assertion or a test around the handler so a future refactor does not silently drop it. Keep the debug flag in your development workflow and treat every event like a small feature with a test, not a throwaway line of code. That discipline is what keeps a custom-event setup useful after the first few months, when memory fades and the person who added the event has moved on.