Why a Separate Homepage Route

Opinion

Yes — build a dedicated GET /api/tools/homepage route. The existing GET /api/tools is a browse/search endpoint: flat, paginated, sortable. The homepage is fundamentally different — it is a curated surface with multiple named sections, each with its own ordering logic and cache TTL. Trying to represent that through query parameters on the browse route is a losing battle.

A dedicated route also lets the frontend fetch the entire homepage in a single request instead of four parallel calls, and lets you cache it as one unit with appropriate TTLs per section rather than fighting with CDN keys for filtered browse pages.

EndpointPurposeOrder
GET /api/tools Browse, search, filter — user-driven discovery rank_score DESC (default), any sortBy
GET /api/tools/homepage Main site page — curated, slot-based, multi-section Fixed slot-layer architecture (see below)

Slot-Layer Architecture

The homepage listing is not a flat ranked list. It is a stack of layers, each filling a fixed number of slots. Tools in a higher layer are always shown before tools in a lower layer. Within each layer, the ordering rule is different.

Layer 1
Admin Featured
Featured Tools
Tools manually marked featured = true by admin. Max 6–12 slots. Ordered by featured_at DESC (most recently featured first) or by admin-set position. Never mixed with other layers — always occupies its own dedicated section on the page.
Admin only Max 12 slots CDN purge on change
Layer 2
Paid Promoted
Promoted Tools
Tools with an active promotion record (slot = 'homepage', promoted_until > now()). Max 3–5 slots. Visually labelled "Sponsored" so users know. Ordered by promoted_from ASC (first to purchase wins the top promoted spot).
User-purchased Max 5 slots Short TTL or no cache Labelled Sponsored
Layer 3
New & Rising
New Tools (Step-Down Fade)
Tools published within the last 30 days, boosted by a time-decayed recency_bonus that decreases each week. Provides guaranteed early exposure without permanently inflating rank. Max 8–12 slots. Ordered by rank_score + recency_bonus DESC.
Auto — recompute job Max 12 slots 1h TTL Naturally fades
Layer 4
Ranked
Ranked Tools
All other published tools ordered by rank_score DESC. Tools already shown in layers 1–3 are excluded from this layer to avoid duplication. Paginated — homepage shows first 24, "Load more" continues from here.
Auto — rank_score Paginated 6h TTL

New Tool Step-Down Fade

The previous strategy used a binary recency boost: +30 for 30 days, then 0. This creates a cliff — a tool drops suddenly after 30 days. Replace it with a weekly step-down so the fade is gradual and natural.

+50Week 1
+30Week 2
+15Week 3
+5Week 4
0Day 30+
Day 0–7 Day 8–14 Day 15–21 Day 22–30 Graduated
ℹ️ recency_bonus is computed at recompute-job time (every 6 h), stored in the tools.recency_bonus column, and added to rank_score only in the Layer 3 query. It does not modify the base rank_score used in Layer 4, so a new tool gets its early-stage spotlight without polluting the organic ranked list.

A tool that earns high views and ratings during weeks 1–4 will have a naturally high rank_score by the time recency_bonus hits 0 — it graduates into Layer 4 at a strong position. A tool that gets no engagement gracefully sinks to its true position. No manual intervention needed.

Days since publishrecency_bonusEffect
0 – 7+50Strong launch window — appears high in Layer 3
8 – 14+30Still elevated — second week visibility
15 – 21+15Fading — only high-quality tools hold top spots
22 – 30+5Nearly organic — transition period
30+0Graduated — competes on rank_score alone

Paid Promotion

Opinion — Flat-Fee Time Slots, Not Bidding

Two promotion models exist: flat-fee time slots (pay X for Y days in slot Z) and bid-based auctions (highest current bid wins the slot in real time). Use flat-fee for now.

Bidding is complex, requires real-time slot resolution (kills caching), and creates anxiety for users who don't know if their bid will hold. Flat-fee is simple, predictable, and purchasable through your existing Stripe checkout flow. Move to bidding only if you have enough volume that slots are contested — that's a good problem to have.

Three promotion slot types. Users purchase a slot for a fixed duration. Slots are limited in number so they remain scarce and valuable.

slot = 'homepage'
Homepage Top
Appears in Layer 2 on the main site homepage. Maximum 5 concurrent tools.
Highest value Max 5 slots
slot = 'category'
Category Top
Promoted within a specific category page. User picks which category at purchase time.
Per-category Max 3 per cat
slot = 'search'
Search Boost
Pinned to the top of search results when a user's query matches the tool's tags or name.
Keyword-matched Max 2 per query

Slot availability rule: when a user tries to purchase promotion, the backend checks how many active promotions exist for that slot. If it is at the maximum, the purchase is rejected — or optionally queued for the next available opening. First to purchase wins the open slot; promoted_from ASC breaks ties.

⚠️ Promoted tools must display a visible "Sponsored" label on the frontend. This is not optional — it is a user trust requirement. Search slots in particular must be clearly labelled so users are never misled about organic vs paid results.

Database Schema

New promotions table. Add recency_bonus column to tools.

-- New table CREATE TABLE promotions ( id TEXT PRIMARY KEY, -- CUID tool_id TEXT NOT NULL REFERENCES tools(id), owner_id TEXT NOT NULL REFERENCES users(id), slot TEXT NOT NULL, -- 'homepage' | 'category' | 'search' category TEXT, -- required if slot = 'category' keywords TEXT, -- JSON array, required if slot = 'search' promoted_from DATETIME NOT NULL, promoted_until DATETIME NOT NULL, amount_paid INTEGER NOT NULL, -- cents stripe_payment_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Index for active promotion lookups CREATE INDEX idx_promotions_active ON promotions (slot, promoted_until) WHERE promoted_until > CURRENT_TIMESTAMP; -- New column on tools table ALTER TABLE tools ADD COLUMN recency_bonus INTEGER NOT NULL DEFAULT 0;

Route Design

GET /api/tools/homepage

Public endpoint. No auth required. Returns all four layers in a single structured response. The frontend renders each layer into its own UI section.

Response shape:

{ "featured": [ // Layer 1 — admin curated, max 12 { "id": "...", "name": "...", "slug": "...", "badge": "featured", ... } ], "promoted": [ // Layer 2 — paid promoted, max 5, labelled sponsored { ..., "sponsored": true, "promoted_until": "2026-05-01T00:00:00Z" } ], "new_and_rising": [ // Layer 3 — recency_bonus + rank_score, max 12, last 30 days { ..., "days_live": 4, "recency_bonus": 50 } ], "ranked": [ // Layer 4 — rank_score DESC, excludes ids already above { ..., "rank_score": 142 } ], "meta": { "ranked_total": 284, // total tools for pagination "ranked_at": "2026-04-27T06:00:00Z" // last recompute timestamp } }
ℹ️ The promoted layer is assembled at query time using a live DB lookup (promoted_until > now()). The rest of the response is served from a pre-computed cached view. This means only the promoted section bypasses the cache — see the cache strategy below.

Query logic (pseudo-SQL):

-- Layer 1: Featured SELECT * FROM tools WHERE featured = true AND status = 'active' ORDER BY featured_at DESC LIMIT 12; -- Layer 2: Active Promotions (homepage slot) SELECT t.*, p.promoted_until, p.promoted_from FROM tools t JOIN promotions p ON p.tool_id = t.id WHERE p.slot = 'homepage' AND p.promoted_from <= CURRENT_TIMESTAMP AND p.promoted_until > CURRENT_TIMESTAMP AND t.status = 'active' ORDER BY p.promoted_from ASC LIMIT 5; -- Layer 3: New & Rising (last 30 days, exclude already shown ids) SELECT * FROM tools WHERE status = 'active' AND recency_bonus > 0 AND id NOT IN (:featured_ids, :promoted_ids) ORDER BY (rank_score + recency_bonus) DESC LIMIT 12; -- Layer 4: Ranked (exclude all ids shown above) SELECT * FROM tools WHERE status = 'active' AND id NOT IN (:featured_ids, :promoted_ids, :new_ids) ORDER BY rank_score DESC LIMIT 24;

Cache Strategy

The homepage has a mixed cacheability problem: featured and ranked layers are stable for hours; promoted tools expire at arbitrary times. The solution is to split the response at the CDN layer.

Recommended Approach — Boundary-Aligned Promotions

Require all promotions to start and end at hour boundaries (e.g. midnight, 06:00, 12:00, 18:00). When a user purchases a promotion, the backend rounds promoted_from up to the next hour boundary. This aligns promotion expiry with the cache TTL cycle, so you never need to purge mid-TTL for a promotion ending at a random minute.

Combined with a 1-hour TTL on the entire homepage response, you get: promoted slots that stay valid for the full TTL, expire cleanly at the boundary, and the next CDN miss picks up the updated promotion list. No real-time slot resolution needed.

Section TTL Purge Trigger
/api/tools/homepage (full response) 1h Admin featured change, promotion purchase/expiry at hour boundary
/api/tools (browse) 6h Rank recompute job
/api/tools?category=* 6h Rank recompute job
/api/tools/:slug 24h Owner updates the tool
⚠️ When a promotion is purchased, do not immediately purge the homepage cache. Round promoted_from to the next hour boundary and let the TTL expire naturally. This avoids cache stampedes when multiple promotions are purchased in quick succession.

Publisher Flow

What happens from the moment a user publishes their tool through to it appearing in each homepage layer:

  1. Tool published — row inserted with status = 'active', published_at = now(). recency_bonus starts at +50 (computed immediately at publish time, not waiting for the next recompute job).
  2. Immediately visible — the tool's own detail page (/tools/:slug) is live and shareable right away. No cache issue here.
  3. Appears in Layer 3 (New & Rising) — within the next homepage cache TTL expiry (max 1 hour). The backend purges the homepage cache on publish so this can happen immediately if desired.
  4. Appears in Layer 4 (Ranked) — after the next rank recompute job runs (within 6 hours). Until then it only appears in Layer 3.
  5. Optional: user purchases promotion — goes through Stripe checkout, backend creates a promotions row with promoted_from rounded to next hour boundary. Tool jumps to Layer 2 at that boundary.
  6. After 30 daysrecency_bonus reaches 0 at next recompute. Tool graduates out of Layer 3 and competes in Layer 4 on rank_score alone. A tool that earned good views and ratings during weeks 1–4 will have a naturally strong rank_score by graduation.

Decision Summary

QuestionDecisionControl
Dedicated homepage route? Yes — GET /api/tools/homepage Automatic
Featured layer Admin-curated, max 12 slots, immediate purge on change Admin only
Paid promotion model Flat-fee time slots (not bidding), 3 slot types User-purchased
Promotion timing Start/end at hour boundaries to align with CDN TTL System-enforced
New tool visibility Layer 3 with step-down recency_bonus (+50→+30→+15→+5→0) Recompute job
Natural fade mechanism Weekly step-down, graduates into Layer 4 at day 30 Automatic
Homepage cache TTL 1 hour — matches promotion boundary alignment CDN + recompute
Browse route unchanged? Yes — GET /api/tools stays flat, rank_score sorted Automatic