Batches & Products
The weekly launch model — how batches form, when they publish, and how products appear on the homepage.
Launchy's core mental model is weekly batches. Each week, a new batch of products goes live on the homepage. Past batches live in an archive at /batches/{year}/{week}.
The lifecycle
User submits → moderation → accepted & scheduled → batch publishes → visible on /
│ │ │ │ │
/submit /admin/submissions (next cron) cron flips publishedAt users voteHow a batch publishes
The cron route app/api/cron/weekly-batch/route.ts runs on the schedule defined by your settings. On each run:
- Authenticates via
Authorization: Bearer $CRON_SECRET(timing-safe comparison) - Creates a batch row for the current ISO week/year if one doesn't exist (idempotency via DB check, no Redis lock)
- Collects all
submissionswithstatus = 'accepted'scheduled for the week - Creates
productsrows for each - Computes the dofollow flag on each new product: top N products by votes (excluding highlight tier) + all Boost/Highlight tiers →
dofollow = true - Sets
batches.published_at = now() - Creates + sends a Plunk campaign (weekly newsletter) to subscribers tracked in Plunk
- Revalidates
/and/batches
Product tiers
productTier enum values (from lib/db/schema.ts): free, boost, highlight.
| Tier | Behavior | Newsletter |
|---|---|---|
| Free | Enters the submission queue. Publishes when its scheduled batch runs. | Included if top N by votes (N = dofollow_top_n, default 3) |
| Boost | Paid via Stripe. On checkout.session.completed, submission is auto-accepted and scheduled to the next open week. | Guaranteed — always included |
| Highlight | Paid via Stripe. On checkout.session.completed, product is instantly published to the current batch and gets a homepage pin for highlight_duration_days. | Guaranteed — always included |
Default prices (editable in /admin/settings → pricing):
boost_price_centshighlight_price_cents
Position & sorting
Within a batch, products are ordered by position (set at insertion time by the cron or Highlight auto-publish, with paid tiers first). The homepage also sorts live by vote count on top of that base order.
Dofollow rule
At publication time, the cron flips dofollow = true on:
- All products with
tier = 'boost'ortier = 'highlight' - The top
dofollow_top_nfree-tier products (by vote count in that batch)
Default dofollow_top_n = 3 (configurable in settings). Everything else renders with rel="nofollow sponsored".
Highlight expiration
Highlight products get highlight_expires_at = now() + highlight_duration_days when they're published. A second cron at app/api/cron/cleanup-highlights/route.ts runs periodically to demote expired highlights back to tier = 'boost' (keeping their dofollow). It revalidates / when it runs.
Product detail pages
Every product has a detail page at /p/{slug} (app/p/[slug]/page.tsx). It includes:
- Thumbnail + logo + rich description (HTML from Tiptap)
- Outbound "Visit" button with the correct
relattribute - Vote button
- Threaded comments (1-level)
- OG image for social sharing
Batch archive
Every published batch is permanently accessible at /batches/{year}/{week}. Included in the sitemap.
Customizing the schedule
Settings that drive publication timing (in /admin/settings → schedule):
| Key | Purpose |
|---|---|
launch_day | ISO day-of-week the cron expects to publish on |
launch_hour_utc | UTC hour |
free_slots_per_batch | Max free-tier products per batch (overflow stays queued for next week) |
highlight_duration_days | How long a Highlight product stays pinned |
dofollow_top_n | Top N free-tier products promoted to dofollow |
The cron runs whenever your scheduler calls it — the settings inform the data rules, not the cron schedule. Schedule the cron itself in your provider (see Cron Jobs) to fire at the desired day/hour.
Seeding a demo batch
For local testing:
bun scripts/seed-products.tsThis inserts a batch with 9 demo products. Delete them later from /admin/products.