Skip to content

seo: route /pricing /stories /content, fix /help subpaths + locale trailing-slash dupes - #2682

Closed
0xkkonrad wants to merge 2 commits into
devfrom
seo/redirects-bundle
Closed

seo: route /pricing /stories /content, fix /help subpaths + locale trailing-slash dupes#2682
0xkkonrad wants to merge 2 commits into
devfrom
seo/redirects-bundle

Conversation

@0xkkonrad

@0xkkonrad 0xkkonrad commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Part of the SEO code track (12 Aug 2026). Redirect/reserved-route layer only — no page or content changes. Sibling PRs in the same track touch robots.ts/canonicals/corridors, the username catch-all's robots meta, and sitemap.ts.

Defects

1. /pricing, /stories, /content render a payment profile, not the page they name

All three ship as real pages under src/app/[locale]/(marketing)/, but only at their locale-prefixed paths. The bare paths are seven lowercase letters, so couldBeRecipient() (src/constants/routes.ts) accepted them as usernames and src/app/[...recipient]/page.tsx served a payment-profile shell — HTTP 200, wrong content, indexable.

$ curl -so /dev/null -w '%{http_code}\n' https://peanut.me/pricing   # today, production
200

2. /help/:path* soft-404s — including the Google Play account-deletion URL

redirects.json redirected bare /help but never its subpaths, so every help article at its bare path fell into the same catch-all shell. /help/delete-account is the account-deletion URL required by Google Play's data-safety policy, and it returns a 200 payment shell today:

$ curl -so /dev/null -w '%{http_code}\n' https://peanut.me/help/delete-account   # today, production
200
$ curl -so /dev/null -w '%{http_code}\n' https://peanut.me/en/help/delete-account
200

3. Trailing-slash duplicates across the whole locale tree

skipTrailingSlashRedirect: true (next.config.js) turns off Next's global trailing-slash redirect, so /en/help/ and /en/help both return 200 — two URLs, one page, for every marketing page in every locale.

$ curl -so /dev/null -w '%{http_code}\n' https://peanut.me/en/help/   # today, production
200

4. docs.peanut.to has no host rule. docs.peanut.me has had one since the docs site was retired; the .to twin was never added. It is currently limping along on a Namecheap URL-forward that only works over plain HTTP — https://docs.peanut.to/ times out, because Namecheap forwarding has no certificate for it:

$ curl -sI http://docs.peanut.to/
HTTP/1.1 301 Moved Permanently
Location: https://peanut.me/en/help
X-Served-By: Namecheap URL Forward

$ curl -sI https://docs.peanut.to/
curl: (28) Connection timed out after 20002 ms

Fixes

redirects.json (+ three entries in DEDICATED_ROUTES, + a comment in next.config.js):

source destination code
/help/:path* /en/help/:path* 308
/pricing /en/pricing 308
/stories /en/stories 308
/stories/:path* /en/stories/:path* 308
/content /en/content 308
/:path* (host docs.peanut.to) https://peanut.me/en/help 308
/:locale(en|es-419|es-ar|pt-br)/:path*/ /:locale/:path* 308

pricing, stories, content added to DEDICATED_ROUTES so isReservedRoute() stops the catch-all claiming them.

/stories/:path* is here because /stories/[slug] exists: reserving stories without it would turn live story URLs from a wrong-but-200 shell into a hard 404.

Why skipTrailingSlashRedirect stays

Removing it is the obvious one-line fix for defect 3 and it is the wrong one. Next's built-in redirect is global, and the PostHog reverse proxy (/relay/:path* rewrite, next.config.js) is called by the SDK with trailing slashes (/relay/decide/, /relay/e/) over POST. A 308 there either drops the body or costs every analytics event an extra round trip. The flag stays; the slash-stripping redirect is scoped to the four SUPPORTED_LOCALES prefixes instead, so it structurally cannot reach /relay, /monitoring (Sentry tunnel), /passkeys or the recipient catch-all. A comment on the flag records this so the next person doesn't "simplify" it.

308 vs 307

The new redirects are permanent: true (Next emits 308, which Google treats as 301). These are duplicate/soft-404 URLs being consolidated, so signals should pass through. Bare /help, /terms, /privacy keep their existing 307 — untouched, to keep the hunk minimal. If locale negotiation ever lands on these paths, flip permanent to false first: 308s are cached by browsers indefinitely.

/privacy/:path* and /terms/:path* deliberately NOT added

Both are single page.tsx files with no [slug] child, so there is nothing under them to redirect. Adding a :path* rule would manufacture destinations that 404.

Action required from the backend owner

stories and content are still claimable as usernames. Frontend routing does not stop signup. Checked against the live username API (HEAD /users/username/{u} → 200 taken / 400 reserved / 404 free):

pricing -> 400   (already server-reserved)
stories -> 404   (FREE — anyone can claim it)
content -> 404   (FREE — anyone can claim it)

Please add stories and content to the server-side reserved-username list. Until then a user can register stories, and this PR's redirect will quietly shadow their profile.

Verification

next build cannot run on the authoring box (earlyoom SIGTERMs it), so evidence is (a) Next's own route compiler run locally against the edited file and (b) curl -i against this PR's Vercel Deploy Preview.

Local — Next's own redirect compiler

checkCustomRoutes is the validator next build runs before anything else; buildCustomRoute is the function that writes routes-manifest.json, which is exactly what the Vercel proxy executes. Both were run against the edited redirects.json with Next 16.2.3 from this repo's lockfile:

checkCustomRoutes(redirect): PASS (40 rules)

Compiled regexes, evaluated in array order (first match wins), destination fed back in to catch loops:

/help -[307 via "/help"]-> /en/help

/help/delete-account -[308 via "/help/:path*"]-> /en/help/delete-account

/pricing -[308 via "/pricing"]-> /en/pricing

/stories -[308 via "/stories"]-> /en/stories

/stories/kudi -[308 via "/stories/:path*"]-> /en/stories/kudi

/content -[308 via "/content"]-> /en/content

/en/help/ -[308 via "/:locale(en|es-419|es-ar|pt-br)/:path*/"]-> /en/help

/en/send-money-to/brazil/ -[308 via "/:locale(en|es-419|es-ar|pt-br)/:path*/"]-> /en/send-money-to/brazil

/pt-br/help/ -[308 via "/:locale(en|es-419|es-ar|pt-br)/:path*/"]-> /pt-br/help

/es-419/ -[308 via "/:locale(en|es-419|es-ar|pt-br)/:path*/"]-> /es-419

/relay/decide/  (no redirect -> app router)

/monitoring/  (no redirect -> app router)

/api/og/  (no redirect -> app router)

/passkeys/login/verify/  (no redirect -> app router)

/kkonrad/  (no redirect -> app router)

Note the shape of every result: exactly one hop, no rule loops back onto its own source, and /relay/*, /monitoring/, /api/og/, /passkeys/* match nothing.

Preview — curl -i

preview origin: https://peanut-wallet-git-seo-redirects-bundle-squirrellabs.vercel.app
(status + Location, from `curl -sS -D -`)

# T8 — the Google Play account-deletion URL
/help/delete-account            308  ->  /en/help/delete-account
/en/help/delete-account         200        (destination is real, not a 404)
/help                           307  ->  /en/help        (untouched, still the existing 307)

# T3 layer 2 — marketing hubs
/pricing                        308  ->  /en/pricing
/stories                        308  ->  /en/stories
/stories/arsenii                308  ->  /en/stories/arsenii
/content                        308  ->  /en/content

# T5 — trailing slash, locale tree only
/en/help/                       308  ->  /en/help
/en/send-money-to/brazil/       308  ->  /en/send-money-to/brazil

# PostHog proxy — must still proxy, must not redirect.
# Preview and production are byte-for-byte the same behaviour:
                                     PREVIEW                         PRODUCTION
GET  /relay/static/array.js          200 application/javascript      200 application/javascript
POST /relay/decide/?v=3              400 text/plain (no body sent)   400 text/plain (no body sent)
                                     ^ no Location header on either — the rewrite still wins

CI

prettier --check, eslint, tsc --noEmit, jest and the Playwright e2e job all run on this PR — see the checks below.

Conflicts to expect

  • feat: host creator contest on peanut.me #2605 (host creator contest, base dev) adds 'creator-contest' to DEDICATED_ROUTES. Same array, a few lines above this PR's block — trivial textual conflict, both additions are wanted.
  • fix(seo): harden Split public content boundary #2671 (fix(seo): harden Split public content boundary, base main) rewrites the locale entries at the top of redirects.json, adjacent to the /help block this PR edits. It reaches dev via back-merge on someone else's schedule; expect a conflict there and keep both sides.

Caveats / follow-ups

  • /press and /team have the identical defect (200 payment shell at the bare path, real page at /en/press and /en/team). Both are already server-reserved (400 from the username API), so the fix is safe — left out only to keep this PR's scope to the three routes it was scoped to. Straightforward follow-up: same two-line pattern.
  • The docs.peanut.to rule is inert until ops moves the domain. It resolves to 192.64.119.224 (Namecheap), not Vercel — a has: host rule can only fire on requests that reach this project. Adding docs.peanut.to as a domain on the peanut-wallet Vercel project and repointing DNS activates it and fixes the HTTPS timeout above (Vercel issues the cert). The rule is landed now so the domain move should need no further deploy — but note this is untested until the domain is actually attached (review probed the preview with a spoofed Host header and a different Vercel project answered, so the rule couldn't be exercised end-to-end). docs.peanut.me is already on Vercel and 308s correctly today, which is the precedent this copies.
  • The locale list in the trailing-slash rule is a literal copy of SUPPORTED_LOCALES (src/i18n/types.ts). A fifth locale needs adding in both places; the comment on skipTrailingSlashRedirect says so.
  • /en/deposit/via-avalanche/ and friends still resolve in one hop — the existing /:locale/deposit/* rules sit above the new slash rule and already emit slashless destinations. Verified in the simulation above.

🤖 Generated with Claude Code

…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.

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 2:37pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1cc9111a-6729-447e-bf66-6cab6eca3cce

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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: 7158.83 → 7158.96 (+0.13)
Findings: 0 net (+1 new, -1 resolved)

🆕 New findings (1)

  • medium complexity — src/constants/routes.ts — CC 14, MI 61.4, SLOC 46

✅ Resolved (1)

  • src/constants/routes.ts — CC 14, MI 61.44, SLOC 46

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2965 ran, 0 failed, 0 skipped, 51.5s

📊 Coverage (unit)

metric %
statements 66.5%
branches 51.7%
functions 56.5%
lines 67.3%
⏱ 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.4s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
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/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.2s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

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.
@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Re-probe after cf83f8184 (:path*:path+) — all clear on the rebuilt preview.

The review's blocker is gone: bare locale homepages no longer self-redirect, everything the rule was meant to do still works.

/es-ar/                    -> 200            (was: 308 self-loop)
/es-419/                   -> 200            (was: 308 self-loop)
/pt-br/                    -> 200            (was: 308 self-loop)
/en/                       -> 308 -> /       (pre-existing /en rule, unchanged)
/en//                      -> 308 -> /en/ -> / (terminates)
/es-ar/help/               -> 308 -> /es-ar/help
/en/help                   -> 200            (no reverse loop)
/en/send-money-to/brazil/  -> 308 -> /en/send-money-to/brazil
/help/delete-account       -> 308 -> /en/help/delete-account
/pricing /stories /content -> 308 -> /en/{x}
follow /es-ar/ (max 6)     -> final /es-ar/ 200, 0 redirects

Also validated offline with Next's own checkCustomRoutes (40 rules PASS) + compiled-rule replay before pushing. PR body's docs.peanut.to paragraph softened per the review (rule is untestable until the domain attaches to this project).

🤖 Generated with Claude Code

@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
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.

1 participant