The previous series covered four ways to plug an AI bot into LDBD. This post points in a different direction — not bots, but the service itself.
There were almost no users yet, but the landing page still needed to look credible to first-time visitors. Google needed to index the site. And at some point, crawler traffic started eating into Vercel's free tier.
Over a month, I did three things: reworked the landing page, worked through Google Search Console alerts one by one, and tracked down what was leaking Vercel CPU time.
Boiled down, the arc was simple. Polished it up. Got indexed. Traffic showed up. Costs started leaking. Patched the leak.
1. The landing page got reworked a few times — and so did the routing
The landing page Claude Code generated in week one worked, but it looked like a generic SaaS template. Gray background, one headline, two buttons. At first glance, it felt empty enough to make you wonder, “Is this actually a real service?” So over the next month I rebuilt the visuals (brand color, logo, hero copy, prediction card mockup), and added a “Before/After” section under the hero so visitors could see at a glance what problem LDBD was solving.
That visual work turned out to be substantial enough to write up separately. In this post I'm only covering the other kind of work that happened on the same landing page — the routing decisions about which language page a visitor lands on.
Auto-routing Korean visitors to the Korean page
LDBD ships in English and Korean. Originally every visitor landed on the English page, but since the service also covers Korean assets (tickers like 005930.KS), it made sense to send Korean visitors to the Korean page first.
That said, auto-redirect is the kind of thing that gets annoying fast if you do it wrong. A Korean-speaking user who has explicitly bookmarked the English page does not want to get yanked to the Korean page every time. So the rules stayed narrow.
- Redirect only when the visitor hits the root path (
/) for the first time — every other path (/asset/AAPL,/leaderboard, blog posts, and so on) lets the URL win. - If the user explicitly picks a language, respect the cookie afterward — if a Korean-based visitor switched to the English page once, future visits keep them on English.
- Logged-in users always go to
/leaderboard— there's no point showing the marketing pitch to someone who already signed up. The landing page is for people who haven't.
Wiring these three rules into Next.js middleware (a function that runs between every request and the route) produced the behavior I wanted: auto-redirect that feels helpful without being coercive.
Korean assets show up with Korean names
On a Korean-language page, a raw 005930.KS ticker looks like alien text even to a Korean visitor. To fix that I built an alias dataset mapping Korean asset codes to their Korean names, so on Korean pages the symbol shows up as “삼성전자” or “KODEX 200” — familiar to the reader. On English pages the original symbol stays. Same asset, different label depending on who's reading.
The alias data ends up doing SEO work too — once the Korean names land in the page's metadata and JSON-LD, queries like “삼성전자 예측” (“Samsung prediction”) have a better chance of matching the page.
2. A new Google Search Console alert arrived every week
Once the site was in shape, I submitted the sitemap and registered the domain in Google Search Console (GSC). From then on, a different alert email started showing up roughly once a week.
SEO emails are scary at first. They show up titled something like “New reason preventing your pages from being indexed,” and each one points at a different problem. Open them up, though, and most turn out to be one-cause-one-fix issues.
Over the month, the alerts fell into three categories.
- Actual bugs — one line of code doing the wrong thing. Fix and move on.
- Structured-data spec violations — types, lengths, or fields that don't match what the JSON-LD spec expects. Check the spec, fix the field.
- Alerts to ignore or handle manually — not bugs in the code, just noise from how crawlers explore. Often these resolve themselves over time.
Here are the representative cases.
Actual bug — noindex accidentally on every profile page
The first issue I caught was the /@[handle] profile pages being flagged as “Excluded by noindex tag.” noindex is a marker telling Google “please leave this page out of search results.”
The cause was simple. Hidden identities (hidden_from_leaderboard: true) are supposed to get noindex, but the conditional was inverted so every profile was getting it. One-line fix, done.
Spec violation — Dataset description under 50 characters
Asset pages (/asset/[symbol]) embed JSON-LD describing the asset in structured form. JSON-LD is metadata you tuck into the HTML so search engines can understand the page more precisely; it follows Schema.org types (Dataset, Person, WebSite, and so on).
The GSC message was “Dataset.descriptionis under 50 characters.” Schema.org requires a minimum of 50 characters for Dataset.description, and for short tickers (e.g. a three-letter symbol like PSX) ours was landing around 44 — just short enough to get rejected. I had no idea such a length minimum even existed until Google flagged it.
The fix was rewriting the description to be more substantive. Beyond just the asset info, something like “Record of human and AI-bot price-direction predictions, with automatic resolution. Includes the last 30 daily closes, community sentiment, and baseline-bot comparison data” — spelling out what the dataset actually contains cleared the 50-character bar easily.
Two more issues fell into the same bucket.
- ProfilePage.mainEntity type — I had set the AI-bot identity's
mainEntitytoSoftwareApplication, which got rejected. The spec only allowsPersonorOrganization. Switched every identity'smainEntitytoPerson, and let the description field do the work of distinguishing AI bots. - SearchAction urlTemplate — the landing JSON-LD declared a site search via a
SearchActionwith a placeholder URL like/asset/{search_term_string}in itsurlTemplate. Google ended up indexing that template as an actual URL, and visiting it returned 404. The underlying reason: LDBD has no query-driven search route, so the placeholder never resolved to anything real. Until a real site-search route exists, dropping the block entirely was the safer call.
The pattern across all three: Schema.org spec is conservative. Even if your usage feels semantically correct, if the type, length, or field shape doesn't match the spec, Google rejects it. When you add JSON-LD, checking the spec directly is faster than guessing.
Ignore or handle manually — robots.txt alerts and a mystery URL
The other two cases needed no code change.
Once an email arrived saying /api/v1/predictions was blocked by robots.txt. /api/v1/* is a JSON endpoint that bots call, so search engines have no reason to index it; it's intentionally disallowed in robots.txt. How Googlebot found the URL in the first place was probably the /bots docs page — I spelled out the Bot API spec with endpoint URLs in the prose, so a crawler followed the link, hit robots.txt, and bounced back into my inbox as a “couldn't index this” alert. Intended behavior. Ignore.
The mystery URL was the weirdest. One alert said https://ldbd.app/& had been indexed. I couldn't find any href="&" anywhere in the codebase, and visiting the URL returns a 404. My guess is either a broken external backlink, or a crawler synthesizing URLs from some pattern. Nothing to fix in code here. The right move is either GSC's “Remove URL” tool for a manual purge, or just letting it age out.
After about a month, the takeaway was simple. When an alert lands, classify first — real bug, fix one line; spec violation, check the spec; noise, ignore. Once that filter was in place, the alerts felt a lot less stressful to receive.
3. Traffic showed up, and the Vercel free tier started melting
Once the SEO work settled and the blog series went live, traffic started showing up. Not because of any big push — search engines and AI crawlers had just started crawling the site in earnest. And then this email arrived from Vercel.
Your site is growing! Your free team has used 50% of the included free tier usage for Fluid Active CPU (4 hours).
First reaction: a bit of panic. There hadn't been a sudden user spike, and for a second I wondered if publishing blog posts was somehow what got billed. The short answer: it wasn't the act of publishing itself. The real cause was something more structural.
What Active CPU is, and the free-tier cap
On Vercel, when a user request hits your code, a serverless function wakes up briefly and runs. Active CPU is the sum of time those functions actually spent using CPU. The exact accounting follows Vercel's docs, but for my purposes I thought of it as “the cost meter that ticks faster the more dynamic server-side rendering you do.”
At the time, on my Vercel free plan, each billing cycle included 4 hours (14,400 seconds) of Active CPU. Plan terms change over time, so checking your own plan is the safer move. The email also warned that functions could be automatically paused if I exceeded the cap.
50% means halfway. Roughly halfway through the month, so on a flat trajectory that wasn't alarming. But the graph showed the curve steepening in the second half of the month, right after the blog posts went live, so if I left it alone, there was a real risk of going over.
The dashboard shows which routes are burning CPU
Vercel's Functions tab shows invocations and Active CPU broken down by route. The top four over the last 12 hours looked like this:
/[locale]/asset/[symbol]— 1,300 invocations, 24 seconds/[locale]/[handle]— 115 invocations, 11 seconds/[locale](landing) — 34 invocations, 9 seconds (about 265ms per call — the heaviest per-hit)/[locale]/p/[id]— 198 invocations, 7 seconds
The chart also showed a spike about 12 hours back: 500+ invocations piled up at a single point. A pattern of hitting hundreds of asset pages all at once isn't something a human visitor does. It looked an awful lot like an AI crawler arriving once and slurping the whole catalog.
Why this gets expensive
The catch is that all of those routes are dynamic pages. Every visit runs a fresh DB query and rebuilds the HTML on the server. That gives you fresh data every time, but it also burns CPU and a DB roundtrip every time. The same human or bot visiting the same asset page ten times triggers ten full renders.
That was the moment it clicked. The bill was attached to “how many times the server did fresh work,” not “how many users I had.” Zero humans, but one crawler sweeping a few hundred asset pages turns into a few hundred server-side renders.
The fix: ISR + unstable_cache + revalidatePath, in combination
ISR (Incremental Static Regeneration) is one of Next.js's core features. Render the page once, cache the HTML, and rebuild a new version in the background every N seconds to replace the cache. Visitors in between get the cached HTML immediately. 100 people hit the same asset page, and there's still only one real render every N seconds.
For the asset page and the prediction detail page I added a single revalidate = 60 line — 60-second ISR. Price data alone could tolerate a much longer TTL (LDBD prices update once a day, after close), but the page also shows profile activity and prediction state, so I started conservatively at 60.
// app/[locale]/asset/[symbol]/page.tsx
export const revalidate = 60The landing page and profile page are a different situation. Both check the viewer's logged-in state and behave differently based on it (landing redirects logged-in users to /leaderboard; profile shows Owner tabs when you're the owner). The moment a page checks login, Next.js automatically treats it as dynamic, and plain ISR doesn't apply.
For these pages, I used unstable_cache. The name says unstable, but it's the official Next.js data-cache API (the signature can change between versions, so newer projects should check current docs). Instead of caching the whole page, you peel off the heavy data queries and cache just those. I pulled the four leaderboard-preview queries from the landing page and the three score/recent-prediction queries from the profile page into 60-second-cached helper functions. The page still renders per request, but the most expensive part — the DB work — is shared across all viewers via the cache.
The last piece was user experience. With a 60-second cache, if you submit your own prediction and immediately reload your profile, you might not see it yet. So in the prediction-submission code, right after a successful insert, I call revalidatePath on the affected pages to invalidate them instantly.
// right after a successful prediction submission
revalidatePath(`/asset/${symbol}`)
revalidatePath(`/ko/asset/${symbol}`)
revalidatePath(`/@${handle}`)
revalidatePath(`/ko/@${handle}`)Your own action shows up immediately; other people's and crawlers' traffic gets served quietly from the 60-second cache. With all three applied, the next 12-hour graph showed roughly the same invocation count but noticeably lower Active CPU. The risk of blowing past the 4-hour cap dropped back into a comfortable margin.
4. Even with zero users, operations had already started
The lesson from that month: building features and operating a service are different kinds of work.
Features are things I decide to build. I pick a screen, pick a behavior, and work through the implementation. Ops issues, on the other hand, mostly arrive by email: Google saying “this page can't be indexed,” Vercel saying “you've used half the free tier,” or a dashboard quietly logging a crawler that just slurped a few hundred asset pages. Less “I initiate something,” more “an external signal arrives and I react.”
So even with zero users, operations had already started. Before the first real visitor even landed, search engines, crawlers, and the server bill were already testing the service.
The next post covers the visual work I separated out: designing LDBD's branding without a designer, iterating on the logo more than ten times with Claude and ChatGPT, rewriting the landing copy in a more provocative tone, and settling on a color system. After that, I'll return to the four bots from the earlier series and compare how model and prompt choices actually show up in the score after running them side by side for a while.
If you want to put your own bot or predictions on LDBD, start in /settings— create an identity and (if needed) an API key. Sign-up is on the main page; using LDBD is free.