Feature: AdSense Ads System (FEAT-41)
Metadata
- Issue ID: FEAT-41
- Status: Done
- Owner: Kzu0-afk
- Related PRs:
41-implement-ads-system->dev - Canonical feature doc:
docs/features/FEAT-41-Implement-Ads-System.md
Overview
Implements a controlled ads layer for StudyBoost using Google AdSense with strict gating rules:
- system-level gate via FEAT-35 feature flag
enable_ads - user-level gate via FEAT-39 subscription state (
freeusers only; active paid users excluded)
The integration keeps ads non-intrusive, fails silently when ad loading fails, and avoids security-sensitive routes.
Frontend Behavior
Verification Surfaces vs. Ad Serving
AdSense review (site ownership + authorized selling) is handled separately from ad serving. Verification surfaces are unconditional — reachable by an anonymous, logged-out crawler on every page — while ad serving stays gated:
GET /ads.txt→frontend/app/ads.txt/route.tsemitsgoogle.com, pub-8538225020218705, DIRECT, f08c47fec0942fa0.<meta name="google-adsense-account" content="ca-pub-8538225020218705">is added to the rootmetadatainfrontend/app/layout.tsx.- Both read the publisher ID from
frontend/lib/seo/adsense.ts(getAdSenseAccountId/getAdSensePublisherId), which usesNEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_IDand falls back to the known publisher ID so verification never fails closed.
The original build only injected the AdSense loader for eligible free users, so Google's anonymous review crawler never saw the publisher ID — leaving the site in "Requires review" / "Ads.txt: Not found". Verification is now global; serving remains gated by the eligibility rules below.
Ad Serving
- Adds a reusable ads module:
frontend/components/ads/ads-provider.tsxfrontend/components/ads/use-ads-eligibility.tsfrontend/components/ads/ad-slot.tsx
- Loads AdSense script lazily only when all eligibility checks pass.
- Hides ads while subscription/flag state is loading.
- Hides ads if:
- feature flag is disabled/missing
- user is active paid (
pro/plus) - AdSense env config is missing
- provider script fails or is blocked
- Renders conservative placements on:
- home discovery page
- courses index
- course detail
- document preview/detail
Backend Behavior
- Extends FEAT-35 known flag enum with:
FeatureFlagName.ENABLE_ADS = 'enable_ads'
- Reuses existing
GET /feature-flags/status/:nameendpoint for runtime ads gating. - No new Ads-specific endpoint added.
- No new database table or migration added for v1.
- Adds
GET /ads.txtas a static Next.js route handler (app/ads.txt/route.ts) for AdSense authorized-sellers verification. It is served by the frontend app, not a staticpublic/asset, so the publisher ID stays sourced from one place.
QA Test Scenarios
| Scenario ID | Description | Steps | Input | Expected Result |
|---|---|---|---|---|
| FEAT-41-01 | Free user sees approved ad placement | Enable enable_ads, configure AdSense env, sign in as free user, open courses page | Free plan, flag enabled | Ad slot renders in approved location without blocking course content |
| FEAT-41-02 | Paid user does not see ads | Enable enable_ads, sign in as active pro or plus user, open same page | Active paid subscription | No ad slot renders and AdSense script is not loaded for ads |
| FEAT-41-03 | Flag disabled hides ads globally | Disable enable_ads, open approved ad route as free user | Free plan, flag disabled | No ad slot renders anywhere |
| FEAT-41-04 | Missing flag safe default | Remove or omit enable_ads, open approved route as free user | Flag missing | Ads remain disabled by default |
| FEAT-41-05 | Subscription loading state | Throttle /subscriptions/my-subscription, open an approved route | Delayed subscription response | Ads do not render until eligibility is known; no flicker from hidden-to-visible paid state |
| FEAT-41-06 | Provider script failure | Block AdSense script or simulate network failure | Free plan, flag enabled, provider unavailable | Page remains usable; no uncaught UI error; ad area does not break layout |
| FEAT-41-07 | Missing AdSense env | Run frontend without AdSense client ID | Free plan, flag enabled, missing env | Ads do not render; no provider request is attempted |
| FEAT-41-08 | Disallowed route suppression | Open login, signup, pricing checkout, or billing-related flow | Any user state | Ads do not render on protected/auth/billing-sensitive pages |
| FEAT-41-09 | Document page non-intrusive placement | Open document detail/preview as eligible free user | Free plan, flag enabled | Ad appears only in side or bottom area; document content remains readable |
| FEAT-41-10 | Layout stability | Load page with slow ad response | Free plan, flag enabled | Main content does not shift significantly and remains interactive |
| FEAT-41-11 | Ad limit enforcement | Open page with multiple content sections | Free plan, flag enabled | Number of ads stays within documented placement limits |
| FEAT-41-12 | No private data leakage | Inspect ad requests and console logs during eligible render | Authenticated free user | No session token, user ID, document content, or private metadata is logged or sent intentionally |
| FEAT-41-13 | ads.txt reachable | GET /ads.txt logged out | Anonymous request | 200 text/plain with google.com, pub-8538225020218705, DIRECT, f08c47fec0942fa0 |
| FEAT-41-14 | Verification meta present | View source of any page logged out | Anonymous request | <meta name="google-adsense-account" content="ca-pub-8538225020218705"> present regardless of flag/subscription |
| FEAT-41-15 | Verification independent of serving | NEXT_PUBLIC_ADSENSE_ENABLED=false, load a page logged out | Serving disabled | /ads.txt and meta resolve; no ad slot or adsbygoogle.js loader injected |
| FEAT-41-16 | Verification survives missing env | Unset NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID, request /ads.txt | Env missing | Falls back to known publisher ID; /ads.txt still returns the authorized line |
QA Verification Notes
- Scoped FEAT-41 backend lint and backend build passed.
- Scoped FEAT-41 frontend lint passed.
- QA bug fix: ads now remain hidden until the current route/config pair has its own resolved
enable_adsstatus, preventing stale flag reuse after route/config transitions. adsense-optimizationpass: lint (app/layout.tsx,app/ads.txt/route.ts,lib/seo/adsense.ts) and a fullpnpm buildfromfrontendboth passed./ads.txtcompiles as a static route and renders the authorized publisher line. The earlier/documents?...&sort=trendingprerender blocker no longer reproduces.
Edge Cases
- Missing flag response defaults to ads disabled.
- Missing AdSense env vars disable ads safely.
- Route not in placement allow-list suppresses ads.
- User plan transitions from free to paid suppress ads after state refresh.
- Ad blockers prevent script load; ads fail closed without breaking page flow.
- Verification surfaces (
/ads.txt,google-adsense-accountmeta) fail open via a hardcoded publisher fallback — the opposite of ad serving — because a missing config value must not block AdSense review. - AdSense-registered host (
studyboost.com) must match the canonical host (www.studyboost.com) or 301-redirect to it, or verification will not resolve.
Notes
- Uses existing FEAT-31 auth/session and FEAT-39 subscription data model.
- Keeps ad rendering policy conservative to preserve study UX.
- No mock ad data is introduced; slot IDs come from environment configuration.
- To re-trigger Google review after deploying: confirm
/ads.txt+ the verification meta resolve on production, then AdSense → Sites → Request review and Search Console → Request indexing for/,/courses,/documents. Ads.txt status can take 24–48h to flip to Authorized. The full runbook lives indocs/features/FEAT-41-Implement-Ads-System.md.