Initial release: business-name finder with live domain availability

Free, no-account tool for naming a business and finding a domain you can
actually register.

Availability uses no paid API and no key. IANA's RDAP bootstrap maps ~1,200
TLDs to their authoritative registry servers (404 = free, 200 = taken); the
handful with no RDAP at all (.io, .co, .me, .sh, .gg, .us) fall back to a DNS
NS lookup and are reported as "probably free" rather than confirmed, because a
registered-but-undelegated domain is indistinguishable that way.

Name generation is pure, deterministic, and client-side — eight strategies over
a curated word bank, ranked by a scorer that does the real quality work. Three
of its guards (prefix-only hint matching, -er-only syncopation, truncation
rejection) exist because of specific bad output and should not be relaxed.

Optional AI suggestions use the visitor's OWN Anthropic/Gemini key from
localStorage, called browser-direct, so this stays free to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JChWdFJPCRxMBxErb8kUVK
This commit is contained in:
2026-08-10 13:40:26 -05:00
commit 78460d35c9
29 changed files with 3644 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
// POST /api/availability { domains: string[] } -> { results: DomainCheck[] }
//
// Server-side because browsers can't do DNS and most RDAP servers send no CORS
// headers. We are an anonymous client against other people's public registry
// endpoints, so this route is deliberately stingy: a hard per-request cap, a
// per-IP token bucket, and bounded concurrency inside checkDomains().
import type { NextRequest } from "next/server";
import { checkDomains } from "@/lib/rdap";
export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // node:dns — not available on edge
/** Most a single request may ask for. A full page of results is ~40. */
const MAX_DOMAINS = 60;
// --- Per-IP token bucket -----------------------------------------------------
// In-memory, so it resets on redeploy and doesn't coordinate across replicas.
// That's fine: this is a courtesy throttle to keep one enthusiastic tab from
// hammering Verisign, not a security control.
const RATE_CAPACITY = 240; // domains
const RATE_REFILL_PER_SEC = 4;
const buckets = new Map<string, { tokens: number; at: number }>();
function clientIp(req: NextRequest): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("x-real-ip") ?? "unknown";
}
/** Returns true when the request may proceed, spending `cost` tokens. */
function takeTokens(ip: string, cost: number): boolean {
const now = Date.now();
const b = buckets.get(ip) ?? { tokens: RATE_CAPACITY, at: now };
const refill = ((now - b.at) / 1000) * RATE_REFILL_PER_SEC;
b.tokens = Math.min(RATE_CAPACITY, b.tokens + refill);
b.at = now;
if (b.tokens < cost) {
buckets.set(ip, b);
return false;
}
b.tokens -= cost;
if (buckets.size > 10000) buckets.clear();
buckets.set(ip, b);
return true;
}
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return Response.json({ error: "Expected a JSON body" }, { status: 400 });
}
const raw = (body as { domains?: unknown } | null)?.domains;
if (!Array.isArray(raw)) {
return Response.json({ error: "Expected { domains: string[] }" }, { status: 400 });
}
const domains = [
...new Set(
raw
.filter((d): d is string => typeof d === "string")
.map((d) => d.trim().toLowerCase())
.filter((d) => d.length > 0 && d.length <= 253),
),
].slice(0, MAX_DOMAINS);
if (!domains.length) {
return Response.json({ results: [] });
}
if (!takeTokens(clientIp(req), domains.length)) {
return Response.json(
{ error: "Slow down a moment — too many lookups from this address." },
{ status: 429, headers: { "retry-after": "30" } },
);
}
const results = await checkDomains(domains);
return Response.json({ results });
}

View File

@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic";
export function GET() {
return Response.json({ ok: true, service: "names" });
}

View File

@@ -0,0 +1,19 @@
import type { NextRequest } from "next/server";
import { createSuggestionCommentsRoute } from "@otm/account-panel/server";
import { giteaConfig, suggestionGuard } from "@/lib/suggestions";
export const dynamic = "force-dynamic";
// The factory types `params` as `{number}|Promise<{number}>` so it can mount on
// either Next 14 or 15; Next 15's generated route validator demands a strict
// `Promise<...>`. These thin re-typed re-exports exist only to satisfy that
// signature check — they add no logic. (Same shape as rpo's.)
const handlers = createSuggestionCommentsRoute({
gitea: giteaConfig(),
guard: suggestionGuard,
});
type Ctx = { params: Promise<{ number: string }> };
export const GET = (req: NextRequest, ctx: Ctx) => handlers.GET(req, ctx);
export const POST = (req: NextRequest, ctx: Ctx) => handlers.POST(req, ctx);

View File

@@ -0,0 +1,9 @@
import { createSuggestionsRoute } from "@otm/account-panel/server";
import { giteaConfig, suggestionGuard } from "@/lib/suggestions";
export const dynamic = "force-dynamic";
export const { GET, POST } = createSuggestionsRoute({
gitea: giteaConfig(),
guard: suggestionGuard,
});

View File

@@ -0,0 +1,21 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { createSuggestionUploadRoute } from "@otm/account-panel/server";
import { suggestionGuard } from "@/lib/suggestions";
export const dynamic = "force-dynamic";
// Pasted screenshots land in this app's own public/uploads/suggestions/ and the
// issue body embeds an origin-relative URL — no shared upload service. The
// registry entry mounts /app/public/uploads as a bind mount so these survive a
// container recreate.
export const { POST } = createSuggestionUploadRoute({
guard: suggestionGuard,
save: async ({ name, bytes }) => {
const safe = `${Date.now()}-${name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
const dir = path.join(process.cwd(), "public", "uploads", "suggestions");
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, safe), Buffer.from(bytes));
return { url: `/uploads/suggestions/${safe}` };
},
});

View File

@@ -0,0 +1,26 @@
import Link from "next/link";
import { CHANGELOG } from "@/lib/version";
export const metadata = { title: "Changelog · Business Name Finder" };
export default function ChangelogPage() {
return (
<>
<h1>Changelog</h1>
<p className="tagline">
<Link href="/"> Back to the name finder</Link>
</p>
{CHANGELOG.map((entry) => (
<div className="entry" key={entry.version}>
<h3>v{entry.version}</h3>
<span className="date">{entry.date}</span>
<ul>
{entry.changes.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
</div>
))}
</>
);
}

374
src/app/globals.css Normal file
View File

@@ -0,0 +1,374 @@
:root {
--bg: #0b0d10;
--panel: #14181d;
--panel-2: #1a1f26;
--panel-border: #232a31;
--text: #e7e9ec;
--muted: #93a1ad;
--faint: #6b7885;
--accent: #4f9cf9;
--accent-dim: #2b6fd0;
--ok: #3ecf8e;
--ok-bg: #10281f;
--warn: #f5b942;
--warn-bg: #33290f;
--taken: #5a646e;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: 32px 20px 80px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.brand {
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
h1 {
font-size: 30px;
margin: 4px 0 6px;
letter-spacing: -0.02em;
}
h2 {
font-size: 19px;
margin: 32px 0 10px;
}
.tagline {
color: var(--muted);
margin: 0 0 26px;
font-size: 15px;
line-height: 1.5;
max-width: 62ch;
}
/* ---------- form ---------- */
.field {
margin-bottom: 16px;
}
.field label {
display: block;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 7px;
}
input[type="text"],
input[type="password"],
textarea,
select {
width: 100%;
background: var(--panel);
color: var(--text);
border: 1px solid var(--panel-border);
border-radius: 10px;
padding: 12px 14px;
font-size: 16px;
font-family: inherit;
line-height: 1.4;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--accent-dim);
}
.hint {
font-size: 13px;
color: var(--faint);
margin-top: 6px;
line-height: 1.5;
}
button {
font-family: inherit;
font-size: 15px;
cursor: pointer;
border-radius: 9px;
border: 1px solid var(--panel-border);
background: var(--panel-2);
color: var(--text);
padding: 11px 18px;
}
button:hover:not(:disabled) {
border-color: var(--accent-dim);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--accent-dim);
border-color: var(--accent-dim);
color: #fff;
font-weight: 600;
}
button.primary:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
}
.controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin: 18px 0 8px;
}
/* ---------- style + tld pickers ---------- */
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
font-size: 14px;
padding: 7px 13px;
border-radius: 999px;
border: 1px solid var(--panel-border);
background: var(--panel);
color: var(--muted);
}
.chip[aria-pressed="true"] {
border-color: var(--accent-dim);
background: #16283f;
color: var(--text);
}
.chip .chip-note {
color: var(--faint);
font-size: 12px;
margin-left: 6px;
}
/* ---------- results ---------- */
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px;
margin-top: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 13px 15px;
display: flex;
flex-direction: column;
gap: 9px;
}
.card-name {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
word-break: break-all;
}
.card-name .tld {
color: var(--muted);
font-weight: 400;
}
.card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 13px;
}
.status {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 13px;
}
.status.available {
color: var(--ok);
}
.status.unverified-available {
color: var(--warn);
}
.status.taken {
color: var(--taken);
}
.status.unknown,
.status.checking {
color: var(--faint);
}
.card.is-taken {
opacity: 0.55;
}
.strategy {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--faint);
}
.register-link {
font-size: 13px;
text-decoration: none;
white-space: nowrap;
}
.register-link:hover {
text-decoration: underline;
}
/* ---------- notices ---------- */
.notice {
border-radius: 10px;
padding: 12px 14px;
font-size: 14px;
line-height: 1.55;
margin: 16px 0;
border: 1px solid;
}
.notice.warn {
background: var(--warn-bg);
border-color: #6a5418;
color: #f2dfae;
}
.notice.info {
background: var(--panel);
border-color: var(--panel-border);
color: var(--muted);
}
.empty {
color: var(--muted);
font-size: 15px;
padding: 34px 0;
text-align: center;
}
/* ---------- footer ---------- */
.footer {
margin-top: 56px;
padding-top: 18px;
border-top: 1px solid var(--panel-border);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
flex-wrap: wrap;
}
.foot-note {
font-size: 12.5px;
color: var(--faint);
max-width: 62ch;
line-height: 1.55;
}
.foot-meta {
display: flex;
gap: 14px;
align-items: center;
font-size: 13px;
white-space: nowrap;
}
.version-chip {
border: 1px solid var(--panel-border);
border-radius: 999px;
padding: 3px 10px;
color: var(--muted);
text-decoration: none;
font-size: 12px;
}
/* ---------- changelog ---------- */
.entry {
border-left: 2px solid var(--panel-border);
padding-left: 16px;
margin-bottom: 26px;
}
.entry h3 {
margin: 0 0 4px;
font-size: 16px;
}
.entry .date {
color: var(--faint);
font-size: 13px;
}
.entry ul {
margin: 10px 0 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.6;
font-size: 14.5px;
}
@media (max-width: 560px) {
h1 {
font-size: 25px;
}
.results {
grid-template-columns: 1fr;
}
}

47
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import Link from "next/link";
import { SuggestionLightbulb } from "@otm/account-panel";
import { VERSION } from "@/lib/version";
import { HAS_AFFILIATE_LINKS } from "@/lib/registrars";
import "./globals.css";
export const metadata: Metadata = {
title: "Business Name Finder · Powered by OTM",
description:
"Free business name generator with live domain availability, checked against the registries themselves over RDAP. No account, no credit card.",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<div className="wrap">
<div className="topbar">
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
Powered by OTM
</Link>
<SuggestionLightbulb />
</div>
{children}
<footer className="footer">
<span className="foot-note">
Availability comes from each registry&rsquo;s own RDAP service, and from a DNS
check for the few extensions that publish none. It&rsquo;s a strong signal, not a
reservation &mdash; a name is only yours once a registrar confirms the purchase.
{HAS_AFFILIATE_LINKS ? (
<> Some registrar links earn us a commission at no extra cost to you.</>
) : null}
</span>
<span className="foot-meta">
<Link href="/settings">Settings</Link>
<Link href="/changelog" className="version-chip">
v{VERSION}
</Link>
</span>
</footer>
</div>
</body>
</html>
);
}

14
src/app/page.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Finder from "@/components/Finder";
export default function HomePage() {
return (
<>
<h1>Find a name you can actually register</h1>
<p className="tagline">
Describe the business, get brandable name ideas, and see which domains are free &mdash;
checked against the registries themselves, live. No account, no credit card, no trial.
</p>
<Finder />
</>
);
}

107
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { loadAiSettings, saveAiSettings, type AiProvider } from "@/lib/ai";
export default function SettingsPage() {
const [provider, setProvider] = useState<AiProvider>("anthropic");
const [key, setKey] = useState("");
const [saved, setSaved] = useState(false);
const [hasStored, setHasStored] = useState(false);
useEffect(() => {
const existing = loadAiSettings();
if (existing) {
setProvider(existing.provider);
setKey(existing.key);
setHasStored(true);
}
}, []);
const onSave = () => {
const trimmed = key.trim();
saveAiSettings(trimmed ? { provider, key: trimmed } : null);
setHasStored(Boolean(trimmed));
setSaved(true);
window.setTimeout(() => setSaved(false), 2500);
};
const onClear = () => {
saveAiSettings(null);
setKey("");
setHasStored(false);
setSaved(true);
window.setTimeout(() => setSaved(false), 2500);
};
return (
<>
<h1>Settings</h1>
<p className="tagline">
<Link href="/"> Back to the name finder</Link>
</p>
<h2>Bring your own AI key (optional)</h2>
<p className="tagline">
The name finder works fully offline without this &mdash; the built-in generator needs no
key and costs nothing. Adding your own API key gets you a second, usually more
imaginative set of suggestions alongside it.
</p>
<p className="notice info">
Your key is stored in <strong>this browser only</strong> and the request goes straight
from your browser to {provider === "anthropic" ? "Anthropic" : "Google"}. It is never sent
to our server and never appears in our logs. Usage is billed to your own account, so use
a key with a spending limit set.
</p>
<div className="field">
<label htmlFor="provider">Provider</label>
<select id="provider" value={provider} onChange={(e) => setProvider(e.target.value as AiProvider)}>
<option value="anthropic">Anthropic (Claude)</option>
<option value="gemini">Google (Gemini)</option>
</select>
<p className="hint">
{provider === "anthropic" ? (
<>
Create a key at{" "}
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer">
console.anthropic.com
</a>
. Starts with <code>sk-ant-</code>.
</>
) : (
<>
Create a key at{" "}
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer">
aistudio.google.com
</a>
. Gemini has a free tier that is plenty for this.
</>
)}
</p>
</div>
<div className="field">
<label htmlFor="key">API key</label>
<input
id="key"
type="password"
value={key}
autoComplete="off"
placeholder={provider === "anthropic" ? "sk-ant-…" : "AIza…"}
onChange={(e) => setKey(e.target.value)}
/>
</div>
<div className="controls">
<button className="primary" onClick={onSave}>
Save key
</button>
{hasStored ? <button onClick={onClear}>Remove key</button> : null}
{saved ? <span style={{ color: "var(--ok)", fontSize: 14 }}>Saved to this browser.</span> : null}
</div>
</>
);
}