Attribution
Your best channel is reporting as “direct traffic”.
A link tapped inside Messages generally passes no referrer. Without UTM parameters, every booking that started with a text gets filed under nothing at all. Five query parameters fix it permanently — build one below.
The builder
Generate a tagged link
Everything is normalised to lowercase with underscores, because most analytics tools treat Spring_Special and spring_special as two different campaigns and will happily split your numbers in half without telling you.
Presets
Where the customer lands after tapping.
The channel, not the vendor.
Keep sms so fallback aggregates.
Required. One name per push.
Optional. Which wording.
Optional. Which audience.
Tagged URL
https://acme.co/book?utm_source=imessage&utm_medium=sms&utm_campaign=spring_service_special
91 characters · valid
How it lands
144 chars
Same thing, in code
tagged("https://acme.co/book", {
campaign: "spring_service_special",
})Reference
What each parameter is for
Three of these are effectively mandatory for messaging. Two are how you learn something beyond “it worked”.
| Parameter | Example | How to use it |
|---|---|---|
| utm_sourceWhere the traffic came from. | imessage | Use the channel, not the vendor. You may switch providers next year; the channel stays the same and your historical data stays comparable. |
| utm_mediumThe kind of channel. | sms | Keep sms for anything landing in the Messages app so iMessage and SMS fallback aggregate into one honest number instead of splitting your reporting. |
| utm_campaignThe specific push. | spring_service_special | Required. One name per campaign, lowercase, underscores. The convention that holds up is {trigger}_{intent}. |
| utm_contentWhich variation of the message. | variant_a | Optional. This is how you A/B two wordings without inventing two campaigns. |
| utm_termWhich audience segment. | lapsed_90d | Optional. Originally a paid-search field; in messaging it is most useful for the segment you targeted. |
Tag before you send, not after
UTM parameters are recorded at the moment of the click. There is no way to retroactively attribute a campaign you sent untagged — that data is simply gone. Get this in place before your first real send.
In your codebase
One helper, used everywhere
Hand-typed UTMs drift within about two weeks — someone capitalises a word, someone uses a hyphen instead of an underscore, and your report quietly splits in half. A single function is the entire discipline.
type Tags = { campaign: string; content?: string; term?: string; source?: string; medium?: string;}; export function tagged(url: string, tags: Tags) { const u = new URL(url); const norm = (v: string) => v.trim().toLowerCase().replace(/\s+/g, "_"); u.searchParams.set("utm_source", norm(tags.source ?? "imessage")); u.searchParams.set("utm_medium", norm(tags.medium ?? "sms")); u.searchParams.set("utm_campaign", norm(tags.campaign)); if (tags.content) u.searchParams.set("utm_content", norm(tags.content)); if (tags.term) u.searchParams.set("utm_term", norm(tags.term)); return u.toString();}The length problem
A tagged URL is long. A long URL in a text looks like a scam.
Both things are true, and the fix is the same one: a short branded link on your own domain that redirects to the tagged destination.
Your own short domain
go.acme.co/spring redirects server-side to the fully tagged URL. Customers see a link they recognise, analytics sees every parameter, and you can change the destination after the message has gone out.
Shortener with a custom domain
A paid shortener on your own domain gets you most of the way there. You are trusting a third party with a link that represents your brand, so pick carefully.
Public shortener
Shared shortener domains carry the reputation of everyone else using them, which includes every spammer. Carrier filters treat them accordingly and your message may never arrive.
import { redirect } from "next/navigation";import { tagged } from "@/lib/tag"; const LINKS: Record<string, { url: string; campaign: string }> = { spring: { url: "https://acme.co/offers/spring", campaign: "spring_service_special" }, book: { url: "https://acme.co/book", campaign: "booking_confirm" }, review: { url: "https://g.page/r/acme/review", campaign: "review_request" },}; export async function GET( _request: Request, { params }: { params: Promise<{ slug: string }> },) { const { slug } = await params; const link = LINKS[slug]; if (!link) redirect("/"); redirect(tagged(link.url, { campaign: link.campaign }));}The point of all this
Report in dollars, not click-through rates.
Tagging is only the plumbing. The output you want is “this campaign cost $94 and returned $1,521”, which is the only version of the sentence anyone makes a decision from.