fix: recover peanut.me technical SEO on main - #2725
Conversation
…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.
(cherry picked from commit 2648b5a)
…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.
(cherry picked from commit 936b012)
The [...recipient] catch-all renders an indexable "X on Peanut" shell for
ANY username-shaped string — existence is only checked client-side, so a
non-existent handle still returns HTTP 200 with full metadata. Google has
been indexing those as thin/soft-404 pages.
Set robots: { index: false, follow: false } on all four generateMetadata
return paths (reserved route, missing recipient, !couldBeRecipient, and the
full metadata object). No carve-outs: real profiles, ETH addresses, .eth ENS
names and request/receipt links are app surface, not search landing pages.
(cherry picked from commit 153c6ba)
Adversarial-review catch: every branch of this route inherits
alternates.canonical '/' from the root layout, so the noindex was
shipping on pages that canonicalize to the homepage - a combination
Google's canonicalization guidance warns can bleed the noindex into
the cluster head (the homepage itself, sitemap priority 1.0).
alternates: { canonical: null } suppresses the inherited value; the
noindex pages now carry no canonical at all.
(cherry picked from commit 69b3e5d)
The [...recipient] route's loading.tsx created a Suspense boundary at the segment. Page() suspends on use(props.params), so React rendered the fallback, flushed the shell with HTTP 200, and only then resolved notFound() — which landed as a NEXT_HTTP_ERROR_FALLBACK;404 marker inside an already-200 response body. Removing the boundary makes the segment's suspension propagate to the shell, so notFound() throws before headers flush and Next sets a real 404 status. The route's render is otherwise synchronous (params guard checks only), and PaymentPage is a client component, so there is no server data fetch left to cover with a fallback. (cherry picked from commit 5cb458a)
…e trailing-slash dupes Four routing defects, all in the redirect/reserved-route layer: 1. /pricing, /stories and /content are real [locale]/(marketing) pages, but the bare paths are 7 lowercase letters, so couldBeRecipient() accepted them and the [...recipient] catch-all served a payment-profile shell on HTTP 200. Reserved in DEDICATED_ROUTES + 301'd to /en/.... 2. /help/delete-account (and every other help article at its bare path) soft-404'd the same way: only bare /help was redirected, never /help/:path*. That URL is the account-deletion link Google Play requires. Added /help/:path* and /stories/:path* so the newly-reserved prefixes keep resolving instead of hard-404ing. 3. skipTrailingSlashRedirect is on (it must stay — the PostHog /relay proxy is called with trailing slashes), so /en/help/ and /en/help both returned 200. Added one slash-stripping redirect scoped to the locale-prefixed tree only. 4. docs.peanut.to had no host rule; only docs.peanut.me did. (cherry picked from commit 74ca6e5)
Adversarial-review blocker: :path* matches zero segments, so bare locale homepages (/es-ar/, /es-419/, /pt-br/) compiled a 308 redirect to themselves - an infinite loop, cached permanently by browsers. Measured live on the preview; /en/ survived only because the /en -> / rule sits earlier in the array. :path+ requires a segment, so bare locale + slash falls through to its current 200 (matches production). Verified with next's checkCustomRoutes + compiled-rule replay of the loop paths and the intended redirect paths. (cherry picked from commit f5034b2)
Every sitemap URL carried lastModified: BUILD_DATE, so each deploy told crawlers all 709 pages had just changed. That is noise, and it costs the signal on pages that genuinely did change. Content-backed URLs now report the generated_at of the exact file that serves them. 684 of 709 URLs get a real date across 21 distinct values; the remaining 25 (hand-built pages and index pages with no single backing file) keep BUILD_DATE as the fallback. contentGeneratedAt() coerces the frontmatter value to a Date. Note the type/runtime mismatch it guards: ContentFrontmatter declares generated_at as a string, but gray-matter runs js-yaml, which parses unquoted YAML timestamps into Date objects — so both shapes have to work. The lookups read through the cache the has*Content() guards already populate, so they add no file reads. (cherry picked from commit 8ce2b76)
(cherry picked from commit d2765ee)
…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. (cherry picked from commit 8057851)
) - 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 (cherry picked from commit 31bced6)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (24)
💤 Files with no reviewable changes (1)
Included review availability: Your plan includes up to 1 review per rolling hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe PR adds SEO redirects, reserves marketing routes, centralizes content validation, updates crawler and page metadata, derives sitemap dates from frontmatter, and adds tests for these behaviors. ChangesSEO and content integrity
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🔴 Critical · up to The SEO changes have passing validation, but this PR must not merge or deploy until the paired API rejects the reserved names in production and the required approvals are complete; otherwise the UI/API contract could remain exposed. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
|
@coderabbitai review |
|
Code-analysis diffPainscore total: 7096.17 → 7096.21 (+0.04) 🆕 New findings (17)
✅ Resolved (18)
📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
Summary
Task
TASK-21499 — SEO fixes peanut.me
Paired API gate
This UI PR must not deploy until peanut-api-ts #1359 has merged, fully deployed, and rejected both
contentandstoriescase-insensitively in production. Both names were unclaimed in the read-only preflight. If the API needs rollback, roll this UI back first. API #1359 replaces stale-base draft #1355 without rewriting history.Provenance and scope
mainat500109ea49762cb38ea7492685125706d77b76ac.-x -S.9101046215f3eb24c447176d3e99de883c0aacd6. Refresh must preserve and revalidate current main's newer3c2d4cfcontent gitlink.Behavioral decisions
noindex,nofollow, because existence is resolved client-side./api/ogbut retains the full private/app disallow list.es-arremains routable throughes-419content fallback but is omitted from receive-from sitemap entries unless an exact locale file exists.docs.peanut.toDNS/Vercel attachment and legacypeanut.topath preservation are external infrastructure work and are not claimed fixed by this code PR.Local QA
next typegen+ full TypeScript: pass.pnpm validate-links: all 11 passes; 762 published files and 936 independently expected routable URLs.LOCAL_BUILD=truemode; TypeScript passed and 1,088 static pages generated. Full Sentry-wrapped compilation exceeded this shared host's memory and was terminated byearlyoom, so the Vercel preview build remains a hard gate.Preview / runtime QA
dpl_JCdGvVUm3rR6DsDa7aU5kCwDH9Rqis Ready at https://peanut-wallet-26mylgkfs-squirrellabs.vercel.app; GitHub deployment5961963369pins4f531377a9ebde4733dec5696fbda4fad3e4bb4a.es-arfallback canonicals.noindex,nofollowand no canonical./relay/decide/GET/POST is not redirected.Disallow: /policy. The production robots branch is covered by parser-level crawler-group tests and must be rechecked on the real deployment./qaalice001/7USDC. Request and receipt OG endpoints return visually correct 1200×630 PNGs; local and preview hashes match byte-for-byte.en,es-419, andpt-brpages, the true 404, and an unchanged valid-address payment state. The sanitized captures are embedded below from a signed, verified orphan asset commit./%25reaches the app and returns 404. Direct localnext startinstead returns 500 for malformed raw targets, but that path does not ship behind Vercel. A global Proxy expansion was independently rejected because it cannot alter ingress behavior and would broaden middleware over the whole site.Screenshots
Captured at 375×667 on 2026-08-18. The first three rows show the production 404 repaired in the exact-head preview. The last two rows are non-regression controls.
Assets live on temporary branch
pr-assets-2725; delete it after merge.Paid charge-backed request/receipt E2E is explicitly partial: no sanctioned synthetic completed-P2P fixture exists. The exact completed-charge metadata branch has focused unit coverage and both unchanged renderers pass HTTP/visual/hash checks. No production charge was created and no customer receipt capability token was exposed.
Isolated E2E compatibility
testdbPostgres instance, exact UI/API worktrees, source API, and production-built UI.10-user-profile-and-lookuppassed its happy and auth-adversarial paths.e2e-profile-variantspassed 6/6, including a real/update-usercall and exact DB assertion. Its screenshots are not counted as layout proof because the fresh fixture displayed the expected re-consent modal over every screen; the SEO visual proof uses the clean public preview captures above.Architectural review
No blocking smell or hotfix-specific refactor was found. This deliberately preserves client-side username existence lookup: syntactically valid unknown handles remain 200 but are
noindex,nofollowwith no canonical, while invalid shapes return real 404s. Removing the catch-all route-level loading boundary lets Next emit those 404s before streaming; the child payment/profile flows keep their own loaders. Existing cross-repo route ownership and the gray-mattergenerated_attype/runtime mismatch are contained follow-ups, not reasons to widen this production hotfix.Release blockers
Required GitHub checks, Vercel, exact-head CodeRabbit (
No actionable comments were generated), independent review, runtime matrix, and visual review are green. The PR remains draft until the API-first and human-approval gates clear.Out of scope / still open on the parent task
docs.peanut.toandpeanut.toDNS/redirect infrastructure.