Ship
Growing past the first release
The template ships a complete loop: a page that argues, a pricing ladder, checkout, licence keys, downloads, an update feed, the policies and the machine-readable surfaces — all of it working with nothing configured and switching on as each variable is set. This document is what a product grows into afterwards, each with the contract that must not be broken on the way.
Read each section's contract before its code. In every case the code is the
easy part and the contract is what took a production incident to learn.
Opt-in modules with their own setup — a menubar shell, a database, a content
engine — are in docs/modules/.
1. Licence keys and their lifecycle
Where you already are. Both halves exist. On the site,
site/app/api/license/{activate,validate,deactivate} proxy the provider,
site/lib/payments/creem/licence.ts reads a key back off a completed checkout and
finds one again later by email, and site/app/api/license/recover/route.ts is
a self-serve resend. In the app, mac/App/Core/License*.swift hold the key in
the Keychain, activate once per Mac, revalidate weekly and keep working for two
weeks offline. Enable licence keys on each product in the payment dashboard
(docs/go-live.md) and the flow works end to end.
What you might add. A "manage my Macs" list, periodic validation for an
app that stays open for weeks (docs/modules/menubar.md), a trial.
The contract
A provider 401 or 403 is a 502, never valid: false. This is the rule the
whole family is arranged around and the easiest one to lose in a refactor. A
rotated or missing API key is our misconfiguration. Reported as a licence
failure it tells every paying customer their software is unlicensed — support
mail, refund requests, and a very bad afternoon — while the actual fault is one
environment variable. Reported as a server error, their app falls back to its
offline grace window and nobody notices while you fix it.
Each adapter reports a rejected credential as misconfigured (never
refused), and site/lib/payments/licence-routes.ts turns that into the 502
from misconfiguredResponse() in site/lib/payments/http.ts — which makes
this the path of least resistance for every provider.
site/lib/payments/conformance/ holds every adapter to it.
Never lock a customer out mid-session. The client half has to be built around this or the server half does not matter. Validation runs on launch and on a slow schedule — daily, weekly — never in front of an action the user is trying to take. A failed validation starts a grace window measured in days, not a modal. Network failures, provider outages, aeroplanes and corporate proxies are all far more common than licence fraud, and every one of them is indistinguishable from it at the moment of the request. The one thing worse than an unpaid copy running is a paid copy that stopped working on a deadline.
Answers about a licence are never cached. Each adapter's client
(site/lib/payments/creem/client.ts, site/lib/payments/polar/client.ts) refuses a
revalidateSeconds on any /licenses path and says so in the log — a cached
activation would hand a stale "active" to a key that was just deactivated, or a
stale "invalid" to one that was just bought. This is enforced rather than
written down because the caller who gets it wrong will be a future one shaving
a round trip off a hot path.
Recovery answers identically whether or not the address bought anything. Same status, same body, same timing budget. A different answer for "no such customer" turns the endpoint into a tool for discovering who bought your product. The single exception is a send failure — we found the licence and could not deliver it — which leaks nothing an attacker can act on and saves a paying customer from waiting for an email that is never coming.
The key only ever goes to the address that owns it. It is never in the HTTP response of the recovery route, not even on success. Otherwise the endpoint hands licence keys to anyone who can guess a customer's email.
The route paths freeze the day a binary ships. A shipped build cannot be
edited, so every version ever released will ask /api/license/activate for the
rest of time. The response shape freezes with it: an older build has to keep
understanding a newer server's answer. Add fields; never rename or remove one.
2. Downloads, the appcast, and latest-version
Where you already are. /api/download resolves the newest release through
three paths and answers 503 when it cannot; /api/latest-version answers the
same question as JSON for a "Check for updates…" menu item;
site/public/appcast.xml is a Sparkle feed with an empty channel.
What you add. A release pipeline that publishes an asset and appends an
<item> to the appcast, and the two environment variables in .env.example.
The contract
Every link points at /api/download, never at a versioned asset URL. Two
reasons, both learned expensively. An asset URL carries a version number, so
every place that hard-codes one goes stale at the next release. And the one
route is on your own domain, so it survives the binary moving hosts entirely.
Never NEXT_PUBLIC_DOWNLOAD_URL. NEXT_PUBLIC_ values are inlined at
build time, so setting one in a hosting dashboard changes nothing until the next
deploy — production kept rendering the "it arrives by email" fallback while the
variable sat in the dashboard looking perfectly correct, and every check of the
configuration confirmed it was set. DOWNLOAD_URL, unprefixed, is read per
request. That is why the route is force-dynamic; without it the per-request
read is pointless.
503, never 404, and never HTML. A client that asked for a binary and got an error page cannot tell the difference, and some download managers will happily save it under the product's name. 404 says "there is no such thing here", which is false — there is, it is just not resolvable right now. 503 says "ask again later", which is both true and the correct instruction for an updater.
latest-version must never answer { version: null } at 200. A client has
to be able to distinguish "could not check" from "you are up to date", because
only one of those should ever be shown to a person. As a 200 with a null, a
broken lookup on your side becomes an app confidently telling every user they
are current — for however long the breakage lasts, with nobody reporting it,
because nothing looked wrong.
Binaries do not live in site/public/. A hosting platform's static directory is
deployed with the site: a binary there is re-uploaded on every deploy, counted
against bandwidth on every download, and silently replaced the first time
somebody deploys from a branch that does not have it. Releases are immutable and
served from a CDN built for the job.
An empty appcast channel is the truthful answer. Before the first release, "no updates available" is correct. Anything invented there to make the file look finished is an update offer pointing at nothing, and Sparkle will refuse an unsigned or mismatched item anyway.
3. Switching PRICING to "live"
Where you already are. PRICING.mode is "static": the amounts live in
the LICENSE_TIERS table in site/lib/pricing.ts, one row per licence rung.
getPrice() and getTierPrices() throw where a page sells; the *OrNull
twins degrade; site/lib/pricing.test.ts covers both.
What you change. One line in site/lib/pricing.ts:
export const PRICING: PricingConfig = { mode: "live" };
Then set each rung's CREEM_PRODUCT_ID_n, or point its
NEXT_PUBLIC_CHECKOUT_URL_n at a /payment/prod_… link and let the id be
derived from it — the link is public already, so there is no new secret. The
amountCents on each row stops being read; the rows stay, because the device
counts, the copy and the env var names all live there too.
Set every rung before you deploy. getTierPrices() is all-or-nothing: one
rung whose product cannot be resolved rejects the whole ladder, so the visible
symptom of a half-finished switch is the entire pricing section missing rather
than two of three cards. That is deliberate — a price list that has silently
stopped offering something is worse than one that is visibly absent — but it
does mean "the pricing section vanished" reads as some rung, and the thrown
message names which.
The contract
The split stops being theoretical the moment you flip this. Until now
getPrice() and getPriceOrNull() differ only in what they do with a config
that was never going to answer. In "live" mode they differ in what happens
during a provider outage, and the difference is the whole design:
- Pages that sell call
getPrice(), which throws. Next's ISR contract is that a revalidation which throws keeps serving the last successfully generated page. So a two-second blip costs nothing at all. On a cold build there is nothing to keep and the build fails — which is right for a deploy that cannot determine what it is charging. - The root layout, the receipt page and diagnostics call
getPriceOrNull(), which degrades and logs. The layout wraps every route, including the one where a customer recovers a lost licence key, and a price lookup must never stand between a paying customer and that.
Never add a compiled fallback price. It looks like a safety net and is a
bug. A fallback swallows the error, so revalidation succeeds with the wrong
number and overwrites a correct cached page — turning a two-second blip into
an hour of advertising the wrong price to everyone. DEV_PRICE_CENTS
is the sanctioned version of this and is refused in production for exactly that
reason.
Keep the three revalidate windows equal. The in-process memo (OK_TTL_MS),
the fetch layer's next.revalidate, and the revalidate exported by any page
that shows a price. The worst-case gap between what the page says and what the
card is charged is then one window rather than the sum of three.
Read the price once per render and pass it down. A page that calls
getPrice() for the card and again for the JSON-LD can straddle a cache
boundary and tell a crawler one number while showing a reader another.
site/app/page.tsx already does this correctly; keep it that way.
Adding or removing a rung
One row in LICENSE_TIERS, and the rest follows: the card grid, the FAQ's
"how many Macs" answer, the JSON-LD offers array, /buy?devices=, the
per-rung checkout_click property and pnpm run creem:setup all read the
table. Three things are worth knowing before you do it.
The rows must ascend in both devices and price, and the module throws at import if they do not. A ladder that rewards buying fewer Macs renders as a perfectly plausible page, so the check is at load rather than in review.
Exactly one row is primary. It is what getPrice() answers with, where a
bare /buy sends a buyer, and which card is spotlit — and it is the only row
carrying the pre-tier CREEM_PRODUCT_ID / NEXT_PUBLIC_CHECKOUT_URL at the
end of its candidate lists. Moving the flag to another row moves that fallback
with it, which is a live checkout link changing hands. Set the new row's own
variables first.
Do not add a discount claim to make a new rung look better. No struck
price, no "save 40%", no "most popular". The only comparison the cards draw is
perDevice, which is arithmetic on the figure printed directly above it and
therefore checkable by the reader — see AGENTS.md §9 and the note on the
table itself.
4. Phased pricing — the rising ramp
What it is. The first N buyers pay less, then the price steps up. Each phase is its own product in the provider's catalogue, so the amount a customer is charged is always a real listed price rather than a discount computed by us.
export const PRICING: PricingConfig = {
mode: "phased",
phases: [
{ label: "First 50", seats: 50, productIdEnv: "CREEM_PRODUCT_ID_P1" },
{ label: "Next 150", seats: 150, productIdEnv: "CREEM_PRODUCT_ID_P2" },
{ label: "Standard", seats: Infinity, productIdEnv: "CREEM_PRODUCT_ID_P3" },
],
};
getPrice() throws for "phased" today, on purpose and with a message saying
so: resolving the active phase needs a count of seats already sold, which is a
different question from "what does this product cost". Answer that question
first — a provider query for settled orders, or a count from your own orders
table — then read the price of the phase it lands in.
The contract
The count has to be authoritative and cheap. Counting from your own database is right once you have one; counting by paging the provider's order API on every render is not. Cache the phase, not the count, and keep the window equal to the price window.
The ramp must never run backwards. A phase resolved from a stale count can step down — a customer who arrives during a cache miss pays less than one who arrived a minute earlier, and the one who paid more finds out. Resolve to the highest phase any recent read has seen and let it only ever advance.
The checkout link follows the phase. A hosted link pinned to phase one keeps selling phase one after the ramp moved, and the card is charged what the link says, not what the page says. Either mint the checkout server-side per buyer, or re-derive the link from the active phase's product id on every render.
Say what the ramp is on the page. "First 50 at this price" is honest and converts; the same mechanism unstated is a price that appears to change at random. And never state a remaining-seat count you cannot back with the same number the checkout uses.
5. Adopting a database
The template has none, on purpose: licensing is the payment provider's end to end, and the only durable licence state anywhere is the Keychain item on the customer's Mac. That keeps "the server stores nothing" true by inspection. The likeliest legitimate reason to add one is a waitlist you want to keep, or a "manage my Macs" roster — the provider exposes no endpoint listing a key's activations.
docs/modules/database.md is the recipe, including the two things that are
easy to get subtly wrong: nothing may import the database at module scope, and
the build must tell an absent database (skip) from a broken one (fail).
The contract
With no database configured, every route behaves exactly as it does today. Any adoption has to preserve that, and adding a table is a privacy-policy edit in the same commit.
6. The counted redirect
What it is. Outbound links go through a route on your own domain, which records the click and then redirects. Useful for an affiliate link, a directory of tools, a sponsor slot — anywhere the count is something you or somebody else makes a decision from.
Counted server-side rather than with a client beacon, because a beacon is stripped by every content blocker, and a count that is systematically wrong for technical readers is worse than no count at all.
export async function GET(request: Request, context: RouteContext<"/go/[slug]">) {
const { slug } = await context.params;
const target = await resolveTarget(slug);
if (!target) redirect("/");
try {
await recordClick(slug, viewerHash(request));
} catch (error) {
// A counting failure must never stop somebody reaching what they clicked.
console.error("[go] click tracking failed", error);
}
redirect(withUtm(target, SITE.utmSource));
}
The contract
The salt must fail loudly rather than fall back to a literal. This is the warning the section exists for. The version that ships in most codebases is:
// WRONG. Do not write this.
const salt = process.env.CLICK_SALT ?? "unsalted";
It is one character of convenience and it silently destroys the property the
hash was for. A salt of "unsalted" is a published constant: the hash is then
a plain, precomputable digest of an IP address and a user agent, so anybody with
the source — which is everybody, it is in your repository — can confirm whether
a given address clicked a given link. You did not store an IP, you stored
something functionally equivalent, and nothing anywhere reports a problem. The
site keeps working, the numbers keep looking right, and the privacy claim in
your own policy page is now false.
Write it so an unset variable is impossible to miss:
function clickSalt(): string {
const salt = process.env.CLICK_SALT?.trim();
if (!salt) {
throw new Error(
"CLICK_SALT is not set. Outbound-click de-duplication hashes visitor " +
"addresses, and an unsalted hash is a precomputable one — which makes " +
"it an identity we are storing rather than a de-duplication key. " +
"Set any long random string.",
);
}
return salt;
}
Throwing inside the try above is the right place: the click is still
redirected, the count is skipped, and the error is in the log on the first
request rather than never.
The general rule, and it is worth carrying past this one route: a secret with a default is not a secret. A fallback is correct for a feature — a formatting option, a page size — and wrong for anything whose value is the only thing making the mechanism work.
Counting never blocks the redirect. Wrapped in try/catch, always. A
database hiccup must not stand between somebody and the link they clicked.
De-duplicate, and prune what you de-duplicate with. A visitor double-clicking is one click. The hash exists only to notice that, so the rows behind it are pruned on a schedule; it is not an identity you keep.
The route is noindex and excluded from the sitemap. It is a redirect for
humans arriving from a page, not a page. Use an x-robots-tag header rather
than a robots.txt disallow — a disallowed URL's directive is never fetched,
and the URL can still be indexed from links alone.
7. User-generated public content lives on a different domain
When this applies. The day the product lets a customer publish something at a hostname of yours: a share link, a preview URL, a hosted page, a tunnel.
The posture. Provision those hostnames under a separate domain, on a separate zone, with credentials scoped to that zone and no access to your primary one.
The contract
The primary domain must not be able to be taken down by a customer. A public share link puts a hostname of yours in a stranger's URL bar. If one customer ever serves something abusive there and the domain gets flagged by Safe Browsing or a corporate DNS filter, that must not take down your checkout, your licence API, or your marketing site. Those are the three things that cannot be unavailable, and none of them should share a registrable domain with content you do not control.
Scope the credentials to exactly that zone. A DNS token that can also write your primary zone re-couples what the separate domain was meant to decouple. Two permissions, on one zone, and nothing else.
One label deep, and no deeper. A wildcard certificate covers *.example.com
one level down and no further, so a two-level hostname hands the recipient a
certificate warning — which, on a link somebody was sent, reads as an attack.
Generate single-label names.
Half-configured sharing is worse than none. If the feature needs four environment variables, the route answers 502 and names which are missing rather than provisioning something broken. A share link that resolves to nothing has already been sent.
Log nothing about it. Structured logs of share hostnames and who requested them defeat the privacy posture the feature is built around. This is the one place to decline the well-meaning suggestion to add request instrumentation — on purpose, in a comment, so the next reader knows it was a decision.
Where the rules live
Nothing in this document is a rule that exists only here. Each one is written at the code that implements it, which is the copy that stays true:
| Contract | Written at |
|---|---|
| 502 not 401, and never caching a licence answer | site/lib/payments/licence-routes.ts, each adapter's client.ts |
| Neutral answers, and the key only to its owner | site/app/api/license/recover/route.ts |
| 503 not 404, and the build-time inlining trap | site/lib/download.ts, site/app/api/download/route.ts |
| "Could not check" versus "you are up to date" | site/app/api/latest-version/route.ts |
| Throw versus degrade, and no compiled fallback | site/lib/pricing.ts |
| Idempotency, and what a webhook is told | site/lib/payments/webhook.ts |
| Absent versus broken, for the database | docs/modules/database.md, site/scripts/bootstrap.ts |
Why binaries are not in site/public/ | site/public/appcast.xml |
And the conventions all of it depends on: AGENTS.md.
This page is docs/growing.md in the repository, copied 2026-09-25.