I write a small blog on the side. Every time I picked a topic I hit the same wall: Ahrefs and Semrush are $130+ a month, and even when I had the numbers, "volume 590, difficulty 17" didn't tell me whether to write the post or skip it.
So I built Keyword Brief. You type a keyword and get a plain-language GO / MAYBE / SKIP, real Google volume and difficulty, a read of what the top 10 pages actually cover, and an outline with the gaps they left open. It deliberately does not write the article for you.
This post is about how it's built, because the part I'm happiest with is the part users never see: there are no accounts. No signup, no password reset, no user table, and it still takes payments.
The stack
- Next.js on Vercel
- DataForSEO for search volume, difficulty, and the live top 10
- A plain
fetch+ cheerio pass to read the H2/H3 headings off each ranking page (free, and more honest than a "content score") - Claude to turn those headings and the People Also Ask questions into an outline
- Lemon Squeezy for subscriptions and license keys
One check costs me roughly $0.10. Pro is $19.90 a month.
Billing without accounts
Here's the whole model:
- Free tier: 3 checks per browser, counted in an HMAC-signed cookie. You can't edit the number, and I don't need a database row to remember you.
- Paid: a Lemon Squeezy subscription with "Generate license keys" turned on.
- One-click activation: Lemon Squeezy lets you put variables in the confirmation button link. Mine is:
https://keyword-brief.vercel.app/activate?key=[license_key]
After checkout the customer clicks one button, lands on /activate, and the page activates the key for that browser and strips it from the URL. No copy and paste. The same link is on the receipt email as a fallback.
- Staying honest: the server re-validates the stored key at most once an hour. Cancel or refund, and Pro switches off on its own.
// First time a key is seen in this browser: register the browser as one "instance" of the key.
const json = await lsPost("activate", {
license_key: key,
instance_name: `web-${Date.now().toString(36)}`,
});
if (!json.activated) return { ok: false, reason: json.error };
// cookie = sign(`${key}|${json.instance.id}`)
The expensive call (the Claude outline) is a second request, so it's gated too: the first request issues a short-lived, keyword-bound token, and the outline endpoint refuses to run without it. Otherwise anyone could skip the free counter by calling that endpoint directly.
Yes, someone can clear cookies to get 3 more free checks. For a $19.90 tool that's a trade I'll take in exchange for zero signup friction.
Bug 1: every new customer was "inactive"
My first test purchase failed with "This key is inactive."
A fresh Lemon Squeezy key has status inactive. That doesn't mean invalid. It means "not activated on any device yet" (0/5). I was calling /validate and rejecting anything that wasn't active, which means I was rejecting every brand-new paying customer.
The fix is to call /activate the first time you see a key (that's what flips it to active and enforces the device limit), then /validate with the instance_id afterwards, and /deactivate when the user removes the key so the slot is freed.
Bug 2: structured outputs ignored my array limits
I defined the outline schema with zod, including things like "at most 4 points per section":
points: z.array(z.string()).min(1).max(4)
About one request in three blew up with a validation error. Structured outputs guarantee the shape, but array length constraints aren't enforced during generation. The model wrote 5 good points, and my own validator threw the whole response away.
Now the schema has no length limits. The lengths live in the prompt, and I trim in code:
sections: o.sections.slice(0, 9).map((s) => ({ ...s, points: s.points.slice(0, 5) }))
Bug 3: "top 10" returned 6 results
DataForSEO's SERP depth: 10 doesn't mean 10 organic results. It counts every element on the page: videos, People Also Ask, image packs. I was getting 6 organic links and confidently telling users "the top 10 has plenty of small sites."
Ask for depth: 30, filter to type === "organic", take the first 10.
A feature I didn't plan
I'm not a native English speaker, and while testing I typed "how to fishing well". No data at all, because nobody searches that.
Returning "no demand" would have been wrong and a bit insulting. Now, when a phrase has no data, Claude rewrites it into 4 to 6 natural queries, I verify those against DataForSEO in one batch call, and the page shows what people actually search, with volumes: fishing tips (1,600), fishing tips for beginners (320), how to catch more fish (140). Those "no data" lookups don't count against the free checks.
Perceived speed
A full check took about 36 seconds, nearly all of it the outline. I split it in two: the verdict, numbers, and page-one analysis render in about 7 seconds, and the brief fills in underneath while you're reading. Same total time, completely different feel.
Try it
keyword-brief.vercel.app has 3 free checks with no signup. Pro checkout opens this week.
The thing I most want to hear: try a keyword you know well and tell me if the verdict is wrong. The GO / MAYBE / SKIP rules are simple and transparent on purpose, and real counterexamples are how they get better.
Top comments (0)