GrowthRouter Docs Join the waitlist

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 growthrouter and growthrouter-mcp npm packages and the public GitHub repository arrive with v0.1. Commands that need them are marked coming soon.
  • Hosted accounts on app.growthrouter.dev are 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. 1

    Post

    You share a tracked link.

    yoursite.com/?gr=show-hn
  2. 2

    Click

    The tag records the visit and remembers the campaign.

    gr=show-hn
  3. 3

    Confirmed signup

    Your app confirms the signup, ideally from your server.

    verified: true
  4. 4

    Result

    The signup is credited to the post that sent the visitor.

    show-hn +1

Who gets the credit

  1. The last campaign clicked within 30 days wins. A campaign click is a page view whose URL carried ?gr=, as seen by the collector.
  2. If the collector never saw that click, the tag's remembered campaign (gr_last in localStorage) is used, but only if that campaign belongs to your site.
  3. Otherwise the signup counts as "no campaign", listed under the visitor's first outside source: utm_source, or the referring site.
  4. 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).

  1. 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 domain localhost for local tests.

  2. 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.js from your own collector instead. The tag sends events back to the server it was loaded from.

  3. 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-hn
    output
    https://yoursite.com/?gr=show-hn

    You can also write the link by hand: add ?gr=show-hn to any URL on your site.

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

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.

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

  2. 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/register stands for your own signup endpoint.

  3. 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 })
    });
POST /api/signup body
FieldMeaning
siteThe site name (or id).
visitor_idThe value of growthrouter.visitorId(): 16 to 64 hex characters.
verifiedOptional, 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_worked MCP tool.
terminalcoming soon (v0.1, Nov 20)
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.

based on examples/test-site/index.html
<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

AttributeWhat it does
data-siteRequired. Your site's name from the dashboard.
data-apiOptional. Send events to this GrowthRouter server instead of the one the script was loaded from, e.g. data-api="https://your-collector".

JavaScript API

CallWhat 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)

CommandWhat 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 helpUsage.

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.

terminalcoming soon (v0.1, Nov 20)
npm install growthrouter
Export or methodWhat 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.
GrowthRouterErrorThrown 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?

terminalcoming soon (v0.1, Nov 20)
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.

ToolWhat 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_sitesYour sites (name, domain, tag snippet), to find the site name.

Both tools are marked read-only. Then ask in plain words:

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

PartLicense
Tag, SDK, CLI, MCP serverMIT
Collector and dashboardAGPL-3.0. If you run a modified version as a service, publish your changes.

Run it locally

terminalpublic repo coming soon (v0.1, Nov 20)
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

terminal
# 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 to self_host. hosted turns 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.

DataKept as
Page pathe.g. /pricing. The query string is dropped, except the parameters below.
ReferrerOnly 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_contentAs sent.
gr, grpCampaign and post names or ids from your tracked links.
Visitor idA random 24-character hex id in localStorage (gr_vid). Not tied to a person.
Last campaigngr_last in localStorage: campaign, post and time, used for signup credit.
CountryFrom Cloudflare's request data. IP addresses and user agents are not stored.
SignupWhether 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.