Skip to content

seo: fix Googlebot disallows, missing canonicals, and 10 dropped receive-from pages - #2676

Closed
0xkkonrad wants to merge 2 commits into
devfrom
seo/robots-canonicals-receive-from
Closed

seo: fix Googlebot disallows, missing canonicals, and 10 dropped receive-from pages#2676
0xkkonrad wants to merge 2 commits into
devfrom
seo/robots-canonicals-receive-from

Conversation

@0xkkonrad

@0xkkonrad 0xkkonrad commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Three independent SEO hygiene defects, batched because each is a few lines and they share no code.

T1 — Googlebot ignored every disallow

Defect. The Googlebot group in src/app/robots.ts declared only allow: ['/api/og'] with no disallow key. A crawler obeys the single most specific group that matches it, never the union — so Googlebot read that group instead of * and treated every auth-gated route (/home, /profile, /settings, /kyc, /claim, …) as fair game. The one crawler that matters was the one exempted. Live robots.txt confirms this shipped.

Fix. Extracted the * group's disallow list into a shared DISALLOWED_PATHS const and applied it to the Googlebot group too — mirroring how the AI-crawler group in the same file is built. The narrower /api/og allow still wins over /api/ by longest-match, so OG images keep rendering in link previews.

T2 — /lp/card and /careers declared the homepage as canonical

Defect. Both pages call the metadata helper without canonical. src/app/layout.tsx sets alternates: { canonical: '/' }, and src/app/metadata.ts only emits alternates when a canonical is passed — so both pages inherited the root value and told Google their canonical was / while sitting in the sitemap as indexable URLs. Self-canonical was never declared.

Fix. Pass the real path in each generateMetadata() call, matching the existing src/app/exchange/layout.tsx workaround (comment carried over). Neither route has a layout.tsx, so the fix belongs in page.tsx.

T6 — 10 authored countries silently unreachable

Defect. loadReceiveSources() in src/data/seo/corridors.ts seeded from CORRIDORS.map(c => c.from) and then intersected with the receive-from content tree. But receive-from is authored independently of corridors, so the intersection dropped every article whose country was not also a corridor origin — 10 of them: australia, india, kenya, malaysia, netherlands, pakistan, philippines, saudi-arabia, singapore, united-arab-emirates. Written, published, and unreachable.

Fix. Enumerate published receive-from content directly via the existing listPublishedSlugs('receive-from') helper (same semantics the loader already applied: en.md present and published !== false). RECEIVE_SOURCES goes 9 → 19. colombia and mexico stay excluded, correctly — they are corridor origins with no article, the original May-2026 404 case.

The test that had to change

src/data/seo/corridors.test.ts is a checked-in regression guard from the May 2026 no-content incident, and two of its three assertions encoded the old invariant (RECEIVE_SOURCES ⊆ CORRIDORS.from). That subset relation was an artifact of how the list was built, not the thing that protected us. The real invariant is "every entry has a published article". Rewritten to assert exactly that, in both directions — nothing rendered without content (no 404s), nothing authored left behind (no orphans) — plus a duplicate check.

The new guard re-derives the expected set straight off the filesystem rather than reusing the loader's own helper, so it cannot pass by tautology. I verified it has teeth by temporarily restoring the old loader and confirming it goes red, naming the exact dropped countries:

✕ contains every published receive-from article (no orphaned content)
    -   "philippines",
        "portugal",
    -   "saudi-arabia",
    -   "singapore",
        "spain",
    -   "united-arab-emirates",

Verification

$ npx pnpm@10.30.1 exec jest src/data/seo/corridors.test.ts --verbose
PASS src/data/seo/corridors.test.ts
  RECEIVE_SOURCES
    ✓ only contains slugs that have a published receive-from article (no 404s)
    ✓ contains every published receive-from article (no orphaned content)
    ✓ has no duplicate slugs

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
$ npx pnpm@10.30.1 run test          # full suite
Test Suites: 229 passed, 229 total
Tests:       3 skipped, 2925 passed, 2928 total
$ npx pnpm@10.30.1 run lint
✖ 64 problems (0 errors, 64 warnings)   # all pre-existing, none in the 5 changed files

$ npx pnpm@10.30.1 exec prettier --check <the 5 changed files>
All matched files use Prettier code style!

$ npx pnpm@10.30.1 run typecheck     # tsc --noEmit
(clean, exit 0)

T1 — generated robots.txt (served from this branch)

User-Agent: Googlebot
Allow: /api/og
Disallow: /api/
Disallow: /sdk/
Disallow: /home
Disallow: /profile
Disallow: /settings
...
Disallow: /add-money
Disallow: /withdraw

All 24 disallows now present on the Googlebot group, /api/og allow retained, * group unchanged.

T2 — canonical <link> grepped out of the rendered HTML body

$ curl -s http://localhost:4137/careers | grep -o '<link[^>]*rel="canonical"[^>]*>'
<link rel="canonical" href="https://peanut.me/careers"/>     # HTTP 200, 59930 bytes

$ curl -s http://localhost:4137/lp/card | grep -o '<link[^>]*rel="canonical"[^>]*>'
<link rel="canonical" href="https://peanut.me/lp/card"/>     # HTTP 200, 101428 bytes

Checked the response headers too — the only Link: header is font preloads, so the canonical genuinely lives in the HTML, not a header. For contrast, production today:

$ curl -s https://peanut.me/careers | grep -o '<link[^>]*rel="canonical"[^>]*>'
<link rel="canonical" href="https://peanut.me"/>             # the bug
$ curl -s https://peanut.me/exchange | grep -o '<link[^>]*rel="canonical"[^>]*>'
<link rel="canonical" href="https://peanut.me/exchange"/>    # the precedent this fix copies

T6 — the 10 recovered countries

RECEIVE_SOURCES count = 19
RECEIVE_SOURCES = argentina, australia, brazil, france, germany, india, italy, kenya,
  malaysia, netherlands, pakistan, philippines, portugal, saudi-arabia, singapore, spain,
  united-arab-emirates, united-kingdom, united-states
corridor origins = argentina, brazil, colombia, france, germany, italy, mexico, portugal,
  spain, united-kingdom, united-states
NEWLY REACHABLE (has article, not a corridor origin) = australia, india, kenya, malaysia,
  netherlands, pakistan, philippines, saudi-arabia, singapore, united-arab-emirates
origins WITHOUT article (correctly still excluded) = colombia, mexico

The user-visible effect is confirmable on production right now. receive-money-from/[country] calls notFound() for anything outside RECEIVE_SOURCES, and this site's custom 404 returns HTTP 200 — so the dropped pages are soft 404s and status codes tell you nothing. Comparing titles instead:

$ curl -s https://peanut.me/en/receive-money-from/singapore  → <title>Peanut - Instant Global P2P Payments in Digital Dollars</title>
$ curl -s https://peanut.me/en/receive-money-from/zzznotacountry → <title>Peanut - Instant Global P2P Payments in Digital Dollars</title>
$ curl -s https://peanut.me/en/receive-money-from/brazil     → <title>Receive Money from Brazil | Peanut</title>

singapore is byte-for-byte the same shell as a garbage URL; brazil is a real article. That is the defect, live.

e2e was not run. The Playwright suite needs a live API on :5000 serving /dev/test-session for its globalSetup, which isn't available in this environment. No new e2e specs were written.

Caveats for the reviewer

  • Expect a conflict with fix(seo): harden Split public content boundary #2671 (base main), which also edits robots.ts — different hunks (sitemap field + an import), but it lands in dev via back-merge on its own schedule. My hunks are deliberately minimal.
  • RECEIVE_SOURCES growing 9 → 19 adds 10 URLs to the sitemap. That is the intended effect: the content already exists and is already published, it just had no route. Worth a glance at the sitemap diff on the preview deploy.
  • next build never ran to completion in my sandbox, so CI is the first green signal on the production build specifically. Not a code problem, and I checked rather than assumed: this box runs earlyoom with --prefer (^|/)(node|next-server|esbuild), so under memory pressure from other agents building concurrently it SIGTERMs whichever node process is largest. Its own log names the victim — sending SIGTERM to process ... "next-server": badness 1128, VmRSS 3383 MiB — which is exactly the Next.js build worker exited with code: null and signal: SIGTERM reported across three build attempts. The dev server behind the render evidence above was killed the same way twice before it survived long enough to answer. Everything else (typecheck, lint, jest, prettier, and the live render evidence) is green locally.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • SEO Improvements

    • Added canonical URLs for the Careers and Card landing pages.
    • Updated crawler guidance to better control access to API, SDK, and authentication routes while preserving image endpoint access.
    • Improved sitemap and route discovery for published receive-money content.
  • Bug Fixes

    • Ensured all published receive-money source pages are represented accurately without duplicates or missing entries.

…m pages

Three independent SEO hygiene defects.

robots.ts — the Googlebot group declared only `allow: ['/api/og']` with no
disallow key. A crawler obeys the single most specific group that matches it,
so Googlebot was reading that group INSTEAD of `*` and treating every
auth-gated route (/home, /profile, /settings, /kyc, ...) as crawlable. Extract
the `*` disallow list into a shared DISALLOWED_PATHS const and apply it to the
Googlebot group too, mirroring how the AI-crawler group is built. The narrower
/api/og allow still wins over /api/ by longest-match, so OG images keep
working.

careers + lp/card — both call the metadata helper without `canonical`, so they
inherited the root layout's `alternates: { canonical: '/' }` and declared the
homepage as their canonical while sitting in the sitemap. Pass the real path,
matching the existing exchange/layout.tsx workaround.

corridors.ts — loadReceiveSources seeded from CORRIDORS.from and intersected
with the receive-from content tree. receive-from is authored independently of
corridors, so the intersection silently dropped 10 authored countries
(australia, india, kenya, malaysia, netherlands, pakistan, philippines,
saudi-arabia, singapore, united-arab-emirates) whose articles were live but
unreachable. Enumerate published receive-from content directly via the
existing listPublishedSlugs helper. RECEIVE_SOURCES goes 9 -> 19; colombia and
mexico stay out, correctly, as origins with no article.

corridors.test.ts was a checked-in regression guard from the May 2026 no-content
incident asserting RECEIVE_SOURCES subset-of CORRIDORS.from. That subset relation
was an artifact of how the list was built, not what protected us — the real
invariant is "every entry has a published article". Rewritten to assert that in
both directions: no entry without content (no 404s) and no published article
left out (no orphans). Verified the new guard fails against the old loader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 12, 2026 1:15pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 83578f8f-8674-48f9-bbee-8b0d154c2011

📥 Commits

Reviewing files that changed from the base of the PR and between ad5b61b and 622977c.

📒 Files selected for processing (6)
  • scripts/verify-content.ts
  • src/app/careers/page.tsx
  • src/app/lp/card/page.tsx
  • src/app/robots.ts
  • src/data/seo/corridors.test.ts
  • src/data/seo/corridors.ts

📝 Walkthrough

Walkthrough

The change makes published receive-from content the source for receive-source discovery, updates route and sitemap validation, adds canonical URLs for two pages, and centralizes crawler exclusions.

Changes

SEO and indexing behavior

Layer / File(s) Summary
Published receive-source loading
src/data/seo/corridors.ts, src/data/seo/corridors.test.ts
RECEIVE_SOURCES now loads published receive-from slugs directly. Tests verify content coverage and duplicate-free results.
Receive-source route and sitemap integration
scripts/verify-content.ts
Route discovery and sitemap generation now use the argumentless gateReceiveSources().
Page canonical metadata
src/app/careers/page.tsx, src/app/lp/card/page.tsx
The careers and card pages now define explicit canonical URLs.
Shared crawler exclusions
src/app/robots.ts
Googlebot and default crawler rules use shared API, SDK, and auth-gated path exclusions while allowing /api/og.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: hugo0

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the three primary changes: Googlebot disallows, missing canonicals, and restored receive-from pages.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch seo/robots-canonicals-receive-from

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7157.87 → 7159.31 (+1.44)
Findings: 0 net (+4 new, -4 resolved)

🆕 New findings (4)

  • medium complexity — src/data/seo/corridors.ts — CC 11, MI 61.33, SLOC 45
  • medium complexity — src/app/robots.ts — CC 4, MI 60.11, SLOC 32
  • low missing-return-type — src/app/careers/page.tsx:14 — CareersPage: exported fn missing return type annotation
  • low missing-return-type — src/app/lp/card/page.tsx:18 — CardLPPage: exported fn missing return type annotation

✅ Resolved (4)

  • src/data/seo/corridors.ts — CC 14, MI 62.7, SLOC 53
  • src/app/robots.ts — CC 4, MI 60.85, SLOC 30
  • src/app/careers/page.tsx:11 — CareersPage: exported fn missing return type annotation
  • src/app/lp/card/page.tsx:14 — CardLPPage: exported fn missing return type annotation

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2928 ran, 0 failed, 0 skipped, 52.8s

📊 Coverage (unit)

metric %
statements 66.3%
branches 51.2%
functions 56.5%
lines 67.1%
⏱ 10 slowest test cases
time test
3.8s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.2s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.4s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
0.3s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

…comments

Adversarial-review findings on this PR:
- scripts/verify-content.ts gateReceiveSources() was a stale mirror of the
  old corridor-intersection loader — Pass 11 (blocking CI check) silently
  stopped covering the 10 recovered receive-from slugs. Now enumerates
  published receive-from articles directly, matching RECEIVE_SOURCES.
- robots.ts: DISALLOWED_PATHS comment overclaimed ('every named crawler
  group'); reworded to what the file actually does.
- lp/card: canonical comment misattributed the inherited '/' to the root
  layout; it comes from lp/layout.tsx's deliberate alias policy.
@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Adversarial review pass (agent) — verdict: fixable, fixed in 622977c.

Main catch: scripts/verify-content.ts gateReceiveSources() was a stale second copy of the loader this PR rewrites — its docstring claims it mirrors RECEIVE_SOURCES but it still did the corridor-origin intersection, so Pass 11 (the blocking sitemap-coverage CI check, added after the May-2026 mass-404 incident) silently stopped covering the 10 recovered slugs (script said 9, app said 19, both sides agreed with themselves). Now enumerates published receive-from articles directly; validate-links green locally, Pass 11 coverage 1115 → 1165 URLs, and script/app both return the same 19 slugs.

Also fixed two misleading comments the review flagged (robots.ts scope overclaim; lp/card canonical misattribution — it inherits lp/layout.tsx's deliberate alias policy, not the root layout's).

Follow-up noted, not in this PR: the 10 recovered pages have zero inbound internal links from published content — tracked in the content workstream.

🤖 Generated with Claude Code

@abalinda

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@abalinda

Copy link
Copy Markdown
Contributor

/code-review medium

The three fixes are correct. I checked each one:

  • The Googlebot group now carries the full disallow list, and /api/og still wins by longest match.
  • Both canonical additions flow through generateMetadataalternates.canonical and override the layout-level canonical: '/'. Page metadata replaces the alternates key, and neither layout sets languages.
  • listPublishedSlugs('receive-from') reproduces the old gate (en.md present, published !== false) without the corridor intersection. All 10 newly reachable articles have a content/countries/ hub for getCountryName, use only MDX components registered in createMdxComponents, and their internal links resolve to real routes. No sitemap URL is caught by a prefix in DISALLOWED_PATHS.

Six findings below. All low. None blocking.


1. src/app/robots.ts:54 — Twitterbot has the same footgun this PR fixes for Googlebot

The Twitterbot group sets disallow: [], so Twitterbot obeys that group instead of *. Every auth-gated route (/home, /claim, /kyc, /card-payment) stays crawlable for it. The new DISALLOWED_PATHS const makes this a one-line fix.

As it stands, the new comment ("a named group that omits a path silently opts that crawler out of it") documents a bug that is still live two lines below it. Impact is lower than Googlebot, because Twitterbot fetches link previews and does not index.

2. src/app/robots.ts:80 — AI-crawler group omits the money-link routes

The narrower list leaves out /claim, /qr, /receipt, /history, /invite, /card-payment, /add-money, /withdraw. GPTBot, ClaudeBot, and PerplexityBot can crawl claim links and receipts that every other crawler is blocked from. This is pre-existing, but this PR is the moment the intent gets written down.

3. src/app/robots.ts:66 — confirm the Googlebot behavior change before merge

Not a defect. Closing Googlebot's access also blocks /invite, which is the CTA target of every marketing article (peanut.me/invite?code=…), plus /pay, /qr, and /claim. Googlebot had no disallow list before, so these prefixes go from crawlable to blocked. If any of them have indexed pages or organic traffic today, they drop out of the index after this ships. Worth a Search Console check on those prefixes.

4. src/data/seo/corridors.ts:95 — no guard against a meta directory

listPublishedSlugs('receive-from') takes every directory in the tree. The other consumers guard against this: sitemap.ts:165 and sitemap.ts:220 both continue on slug === 'index'.

The content submodule is bumped on its own schedule. An index/ or template directory added there with an en.md becomes a live route and a sitemap URL with no code change here. The new test blesses it instead of catching it. The old corridor intersection prevented this by accident.

5. src/data/seo/corridors.test.ts:17 — frontmatter regex diverges from the loader

The test parses frontmatter with /^published:\s*false\s*$/m. The loader uses gray-matter, which parses YAML. The two disagree on published: False, on published: false # note, and on a published: false nested under another key.

Concretely: an author writing published: False makes the loader drop the slug while the test still expects it. CI turns red on a content-only change, with a confusing set diff as the only clue. Deriving the set independently of the loader is the right call — the parser just needs to match YAML semantics for these few cases.

6. src/data/seo/corridors.test.ts:12 — raw ENOENT when the content submodule is not initialized

fs.readdirSync(RECEIVE_FROM_DIR) throws when src/content is empty, which is a documented worktree trap. The loader swallows this through try/catch and returns [], so the suite used to pass (vacuously) in that state. It now fails with a raw ENOENT that does not name the real cause. CI checks out submodules, so this only hits local worktrees.


Automated review. Findings verified against the branch head.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Consolidated into #2685 (one commit per fix, review catches included) at the CTO's request — this PR's evidence and review thread remain the reference for its slice. Branch kept until #2685 merges.

@0xkkonrad 0xkkonrad closed this Aug 12, 2026
0xkkonrad added a commit that referenced this pull request Aug 12, 2026
…itterbot exemption

Review findings 1+2 from #2676 (abalinda): the AI-crawler group's
6-entry list left claim links, receipts, QR, card-payment, invite and
withdraw crawlable by GPTBot/ClaudeBot/PerplexityBot while every other
crawler was blocked. They now share DISALLOWED_PATHS - AI engines
should read (and cite) content pages, not transactional surface.
Twitterbot's empty disallow is deliberate (card unfurls on shared
claim/payment links) and now documented as such.
0xkkonrad added a commit that referenced this pull request Aug 12, 2026
)

- loader + verify-content gate skip a future 'index' meta directory
  (same guard sitemap.ts already applies to other intents)
- test parses frontmatter with gray-matter instead of a regex, so YAML
  edge cases (published: False, trailing comments) agree with the loader
- test fails with an actionable message when the src/content submodule
  is not initialized, instead of a raw ENOENT
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants