GrowthRouter docs
See which post brought your real signups.
GrowthRouter is a small tag for your site, a link maker for your posts and a collector that counts visits and confirmed signups per post. One campaign id travels from the post to the click to the signup, so the result lands on the post that earned it.
Status: v0.1 ships Friday, November 20
- Everything on this page runs end to end today in local development.
- The
growthrouterandgrowthrouter-mcpnpm packages and the public GitHub repository arrive with v0.1. Commands that need them are marked coming soon. - Hosted accounts on
app.growthrouter.devare not open yet. They will start with a 7-day trial. Join the waitlist to hear when they open. Self-hosting is free.
How attribution works
A campaign id is a readable name such as show-hn. You put it in the link you share, and everything after that is joined on it.
-
1
Post
You share a tracked link.
yoursite.com/?gr=show-hn -
2
Click
The tag records the visit and remembers the campaign.
gr=show-hn -
3
Confirmed signup
Your app confirms the signup, ideally from your server.
verified: true -
4
Result
The signup is credited to the post that sent the visitor.
show-hn +1
Who gets the credit
- The last campaign clicked within 30 days wins. A campaign click is a page view whose URL carried
?gr=, as seen by the collector. - If the collector never saw that click, the tag's remembered campaign (
gr_lastin localStorage) is used, but only if that campaign belongs to your site. - Otherwise the signup counts as "no campaign", listed under the visitor's first outside source:
utm_source, or the referring site. - Each visitor counts once per site. An unconfirmed signup can later become confirmed. Results report confirmed signups.
Quickstart
Four steps from nothing to "which post worked". The hosted dashboard isn't open yet, so today you'd run these against your own collector (see Self-hosting).
-
Add your site
In the dashboard, add a site with a name and a domain. The name is what goes in
data-site: 2 to 41 lowercase letters, digits or dashes. Events are only accepted from that domain and its subdomains. Use the domainlocalhostfor local tests. -
Paste the tag on every page
Put it in the
<head>of every page, including the page people land on from your posts.index.html <script src="https://app.growthrouter.dev/t.js" data-site="yoursite" defer></script>Self-hosted? Load
t.jsfrom your own collector instead. The tag sends events back to the server it was loaded from. -
Make a tracked link for each post
The link maker runs offline. No account, no network call: the campaign is created on its first click.
terminalcoming soon (v0.1, Nov 20) npx growthrouter link https://yoursite.com --post show-hnoutput https://yoursite.com/?gr=show-hnYou can also write the link by hand: add
?gr=show-hnto any URL on your site. -
Confirm signups
Once a signup is real (email verified, account created), tell GrowthRouter. The quickest way is one call in the browser:
after a confirmed signup growthrouter.signup({ verified: true })Anyone can run that from a browser console, so for numbers you can trust, confirm from your server instead. See Confirming signups.
Then read the result in the dashboard, with growthrouter results, or by asking your agent through the MCP server.
Tracked links
A tracked link is a normal URL on your site with up to three extra query parameters.
| Parameter | Meaning |
|---|---|
gr | The campaign. A readable name like show-hn, or a campaign id. |
grp | The post inside the campaign, when one campaign has several posts. Only read together with gr. |
utm_source | Optional. Where the post lives, such as x or hn. |
Names
- Names are lowercased and spaces become dashes: "Show HN" becomes
show-hn. - Allowed characters:
a–z,0–9,-,_and., up to 64 characters. - An unknown name creates a tracking-only campaign on its first click. That's capped at 500 campaigns per site and 200 posts per campaign.
With the CLI
With only --post, the post name is the campaign. Add --campaign to group several posts, and --source to add utm_source.
npx growthrouter link https://yoursite.com --post show-hn
# https://yoursite.com/?gr=show-hn
npx growthrouter link https://yoursite.com/pricing --campaign launch-week --post hook-a --source x
# https://yoursite.com/pricing?gr=launch-week&grp=hook-a&utm_source=x
If the URL has no scheme, the CLI adds https://. --source is skipped when the URL already has a utm_source.
With the SDK
buildLink makes the same link in code, offline. createLink calls the API instead, so the campaign shows in your dashboard before anyone clicks. The URL must be on the site's domain.
import { buildLink, GrowthRouter } from 'growthrouter';
buildLink('https://yoursite.com', { post: 'show-hn' });
// 'https://yoursite.com/?gr=show-hn'
const gr = new GrowthRouter(); // reads GROWTHROUTER_API_KEY
const { tracked_link } = await gr.createLink({
site: 'yoursite',
url: 'https://yoursite.com/pricing',
campaign: 'launch-week',
post: 'hook-a'
});
Confirming signups server-side
Only signups marked verified count as confirmed in results. Confirming from your backend with an API key is the strongest signal, because nobody can fake it from a browser console.
-
Create an API key
In the dashboard, under API keys. Keys are shown once and stored only as a hash. Keep them on the server and pass them as
GROWTHROUTER_API_KEY. -
Send the visitor id with the signup form
The tag gives each browser a random, anonymous id. Read it on the page and send it to your backend with the signup.
signup page // null when Do Not Track or Global Privacy Control is on const visitorId = growthrouter.visitorId(); await fetch('/api/register', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, visitorId }) });/api/registerstands for your own signup endpoint. -
Confirm once the signup is real
For example after the email is verified. With the SDK:
server.jsnpm package coming soon (v0.1, Nov 20) import { GrowthRouter } from 'growthrouter'; const gr = new GrowthRouter(); // GROWTHROUTER_API_KEY, optional GROWTHROUTER_URL (self-hosted) await gr.confirmSignup({ site: 'yoursite', visitorId });Or with a plain HTTP call, from any language:
server.js await fetch('https://app.growthrouter.dev/api/signup', { method: 'POST', headers: { authorization: `Bearer ${process.env.GROWTHROUTER_API_KEY}`, 'content-type': 'application/json' }, body: JSON.stringify({ site: 'yoursite', visitor_id: visitorId }) });
| Field | Meaning |
|---|---|
site | The site name (or id). |
visitor_id | The value of growthrouter.visitorId(): 16 to 64 hex characters. |
verified | Optional, defaults to true. Send false to record an unconfirmed signup. |
The response is { ok: true, action }, where action is insert (new signup), upgrade (an unconfirmed signup became confirmed) or ignore (already counted). The SDK throws a GrowthRouterError with .status and .code on failure, for example 400 bad_visitor.
Reading results
Results list visitors and confirmed signups per campaign and per post, plus traffic with no campaign grouped by source. Three ways to read them:
- Dashboard: sign in on your collector.
- CLI:
growthrouter results, below. - Your agent: the
which_post_workedMCP tool.
GROWTHROUTER_API_KEY=gr_... npx growthrouter results --site yoursite --days 7
It prints one line of totals, then a table with the columns campaign / post, visitors, confirmed and conv, with each post indented under its campaign.
From code, gr.getResults('yoursite', { days: 7 }) returns the same data as JSON: totals, campaigns (each with its posts), unattributed_signups and sources.
GrowthRouter won't name a winner on thin data. It needs 10 or more confirmed signups and two campaigns with 30 or more visitors each. Until then the verdict says "No winner yet".
Frameworks
The tag is one plain script with no dependencies, so it works on any site that serves HTML. There are no framework-specific packages yet.
Plain HTML
This is the example page that ships in the repository: the tag in the head, and a confirmed signup when the form is submitted.
<head>
<script src="https://app.growthrouter.dev/t.js" data-site="yoursite" defer></script>
</head>
<body>
<form id="f"><input type="email" aria-label="Email"> <button>Sign up</button></form>
<script>
document.getElementById('f').addEventListener('submit', function (e) {
e.preventDefault();
// In a real app: call this after the signup is confirmed.
growthrouter.signup({ verified: true });
});
</script>
</body>
Single-page apps
Load the tag once in your root HTML. Navigations made with history.pushState, and back and forward, are counted as page views automatically. If your router changes pages some other way, call growthrouter.pageview() yourself.
The tag loads with defer, so window.growthrouter exists only after the page has parsed. Calling it from event handlers, as above, is safe.
Server side
The SDK has no dependencies and runs on Node 18+, Cloudflare Workers and Deno. Anything else can call POST /api/signup directly.
Tag reference
t.js is plain JavaScript, 971 bytes gzipped in v0.1.0. The build refuses to ship it over 3 KB.
Script attributes
| Attribute | What it does |
|---|---|
data-site | Required. Your site's name from the dashboard. |
data-api | Optional. Send events to this GrowthRouter server instead of the one the script was loaded from, e.g. data-api="https://your-collector". |
JavaScript API
| Call | What it does |
|---|---|
growthrouter.signup({ verified: true }) | Records a signup for this visitor. Only verified: true counts as confirmed. |
growthrouter.visitorId() | The anonymous visitor id, to pass to your backend. null when Do Not Track or Global Privacy Control is on. |
growthrouter.pageview() | A manual page view. Page loads and pushState navigations are already counted. |
Events go to POST /collect on the collector, with navigator.sendBeacon where available and a keepalive fetch otherwise.
CLI reference
The growthrouter command ships in the npm package of the same name. coming soon (v0.1, Nov 20)
| Command | What it does |
|---|---|
growthrouter link <url> --post <name> [--campaign <name>] [--source <name>] | Prints a tracked link. Offline, no account needed. |
growthrouter snippet --site <name> [--host <url>] | Prints the tag to paste in your <head>. --host points it at a self-hosted collector. |
growthrouter results --site <name> [--days <n>] | Visitors and confirmed signups per campaign and post. Needs GROWTHROUTER_API_KEY. |
growthrouter help | Usage. |
Environment: GROWTHROUTER_API_KEY for results, and GROWTHROUTER_URL to point results and snippet at a self-hosted collector. The default is https://app.growthrouter.dev.
SDK reference
The same growthrouter package, as an ES module with TypeScript types. Use API keys on the server only.
npm install growthrouter
| Export or method | What it does |
|---|---|
buildLink(url, { post, campaign?, source? }) | Offline link maker. Returns the tracked URL. |
toKey(value) | Normalizes a campaign or post name the same way links do. |
new GrowthRouter({ apiKey?, baseUrl?, fetch? }) | API client. apiKey defaults to GROWTHROUTER_API_KEY, baseUrl to GROWTHROUTER_URL or https://app.growthrouter.dev. |
confirmSignup({ site, visitorId, verified? }) | Server-side signup confirmation. Returns { ok, action }. |
createLink({ site, url, post, campaign?, source? }) | Same link as buildLink, and the campaign appears in the dashboard right away. Returns { campaign_id, post_id, tracked_link }. |
getResults(site, { days? }) | Visitors and confirmed signups per campaign and post. |
suggestNext(site, { days? }) | Rule-based verdict: is there a winner yet, and what to try next. |
listSites() | Your sites, each with its tag snippet. |
GrowthRouterError | Thrown on failure, with .status and .code. |
The package also contains methods for agent-planned campaigns with human approval (planCampaign, requestApproval and others). They're experimental and not part of v0.1.
MCP server
growthrouter-mcp gives your agent one read-only question to ask: which post brought confirmed signups?
claude mcp add growthrouter -e GROWTHROUTER_API_KEY=gr_... -- npx growthrouter-mcp
# self-hosted: add -e GROWTHROUTER_URL=https://your-collector
Other MCP clients: run npx growthrouter-mcp as a stdio server with the same environment variables.
| Tool | What it returns |
|---|---|
which_post_worked{ site, days? } | Posts and campaigns sorted by confirmed signups, with visitors, conversion rate, the top sources with no campaign, totals, and an honest verdict: "no winner yet" under about 10 signups. |
list_sites | Your sites (name, domain, tag snippet), to find the site name. |
Both tools are marked read-only. Then ask in plain words:
Which post brought real signups this week?
The agent calls which_post_worked({ site: "yoursite", days: 7 }).
Self-hosting
Self-hosting is free, with no limits and no trial. The collector is one Cloudflare Worker with a D1 database: it receives tag events, serves t.js, the API and the dashboard, and handles Google sign-in.
| Part | License |
|---|---|
| Tag, SDK, CLI, MCP server | MIT |
| Collector and dashboard | AGPL-3.0. If you run a modified version as a service, publish your changes. |
Run it locally
git clone https://github.com/thoufeekx/growthrouter
cd growthrouter
npm install
# set SESSION_SECRET in .dev.vars; DEV_LOGIN=1 enables local login
cp apps/collector/.dev.vars.example apps/collector/.dev.vars
# builds t.js, applies D1 migrations locally, runs wrangler dev on :8790
npm run dev
Open http://localhost:8790, use "Dev login (local only)", and add a site with the domain localhost. Dev login needs both DEV_LOGIN=1 and a request from localhost, so it never works in production.
Deploy to Cloudflare
# from the repo root: build t.js
npm run build
cd apps/collector
# then put the printed database_id in wrangler.toml
npx wrangler d1 create growthrouter
npx wrangler d1 migrations apply growthrouter --remote
openssl rand -hex 32 | npx wrangler secret put SESSION_SECRET
npx wrangler secret put GOOGLE_CLIENT_SECRET
npx wrangler deploy
Before deploying, edit [vars] in apps/collector/wrangler.toml:
PUBLIC_URL: your collector's URL. Google's redirect URI is this plus/auth/google/callback.GOOGLE_CLIENT_ID: your own Google OAuth client. Sign-in only asks for the openid, email and profile scopes.PLAN_MODE: remove it, or set it toself_host.hostedturns on the 7-day trial used by growthrouter.dev.
Then point everything at your server: load https://your-collector/t.js on your site, and set GROWTHROUTER_URL for the CLI, SDK and MCP server.
Privacy: what the tag collects
No cookies, no fingerprinting, no cross-site tracking. When Do Not Track or Global Privacy Control is on, the tag does nothing at all: no visitor id is created and nothing is sent.
| Data | Kept as |
|---|---|
| Page path | e.g. /pricing. The query string is dropped, except the parameters below. |
| Referrer | Only the referring site's host, e.g. news.ycombinator.com, and only when it's a different site. |
utm_source, utm_medium, utm_campaign, utm_term, utm_content | As sent. |
gr, grp | Campaign and post names or ids from your tracked links. |
| Visitor id | A random 24-character hex id in localStorage (gr_vid). Not tied to a person. |
| Last campaign | gr_last in localStorage: campaign, post and time, used for signup credit. |
| Country | From Cloudflare's request data. IP addresses and user agents are not stored. |
| Signup | Whether one happened, and whether it was confirmed. No email, no name. |
The collector also drops, without storing them: requests with DNT: 1 or Sec-GPC: 1, known bots, events from a domain other than the site's, and unknown sites.
You're still the data controller for your site, so mention GrowthRouter in your privacy policy. For growthrouter.dev itself, see the privacy policy.
FAQ
Can I use it today?
Not hosted yet. The code runs end to end in local development. The repository goes public and the npm packages are published with v0.1 on November 20. Hosted accounts come later with a 7-day trial: join the waitlist.
Do I need an account to make tracked links?
No. growthrouter link and buildLink run offline, and you can also add ?gr=name to a URL by hand. The campaign is created on the first click.
A visitor clicked two of my posts. Which one gets the signup?
The last campaign they clicked within 30 days.
Why count only confirmed signups?
Because a signup form is easy to fill with junk, and a browser call is easy to fake. A signup your own server confirms, after email verification for example, is the number worth deciding on.
Do I still need UTM tags?
No, but they're recorded. Signups without a campaign are grouped by the visitor's first utm_source or referring site, and --source adds utm_source to a tracked link.
Does it use cookies?
No. The visitor id and the last campaign live in localStorage on your own domain, and nothing at all is stored when Do Not Track or Global Privacy Control is on.
Can my agent post or change anything?
No. In v0.1 the MCP server exposes only read-only tools. You post by hand or with any tool you like.
Is it free?
Self-hosting is free and unlimited. The hosted version will have a 7-day trial and then a paid plan; it isn't open yet.
Found something wrong in these docs? Tell @karbonx6 on X.