Sanity Challenge — Path 2: Vibe-code something strange
Portfolio tracker for niche hobbies (Hot Wheels, Gunpla, Mechanical Keyboards) that proves why structured content beats keyword search. Every valuation is a GROQ aggregation, outlier-aware.
🔗 Links
- Live Demo: https://sanity-challenge.vercel.app
- Collection Demo (GROQ live): https://sanity-challenge.vercel.app/collection
- Sanity Studio (public, no login): https://sanity-challenge.vercel.app/studio
- GitHub (MIT): https://github.com/dnysaz/niche-collector
-
Sanity Project ID:
71m89sy5 -
Dataset:
production— https://71m89sy5.apicdn.sanity.io/v2024-01-01/data/query/production?query=*[_type=="collectibleItem"] -
Organization ID:
os9xuj7u1
Submission tag: #sanitychallenge
What I Built
Niche Collector is a collection portfolio for hobbyists who track value — not just items. Think Hot Wheels Treasure Hunts, Gunpla kits, custom keyboards. The app stores your purchase price vs current market, pulls priceHistory from Tokopedia/Shopee listings, and auto-calculates gain/loss. The trick: a scam Rp 5,000,000 listing is flagged isOutlier=true and excluded from the median — so your portfolio stays honest.
It’s deliberately boring in the best way: no AI chat, just structured data doing work keyword search can’t.
Why this only works with structured content: Try answering “total market vs total purchase for owned grails” with full-text search. You can’t. You need math::sum(*[_type=="collectibleItem" && status=="owned"].currentValue) and a filtered priceHistory join on item._ref. That’s 100% schema-dependent.
Demo — What to Click
- / — landing explains schema + shows GROQ example
-
/collection — live portfolio:
-
TOTAL MARKET Rp 3,900,000vsTOTAL PURCHASE Rp 2,900,000→+Rp 1,000,000 (34%)(computed via GROQ, not client JS) - Cards: Hot Wheels Camaro STH (+733%), Nu Gundam (+31%), Keychron Q1 (-14%) with trend
Tokopedia → Shopee - Outlier filtered: Camaro has 3 history docs, but only 2 shown — the 5M scam is excluded
-
-
/studio — Sanity Studio (embedded NextStudio,
basePath: '/studio', Vision enabled). Try Vision:
*[_type=="priceHistory" && isOutlier != true] | order(recordedAt desc){price, source, "item": item->title}
-
SEO goodies:
/opengraph-image(1200×630 edge),/icon.svg,/sitemap.xml,/robots.txt, JSON-LDWebApplication
If NEXT_PUBLIC_SANITY_PROJECT_ID is missing, /collection falls back to mock — but on Vercel it’s live against 71m89sy5/production (14 seeded docs).
Schema — Thoughtfulness Behind It
5 types in sanity/schemaTypes/ — designed for portfolio math, not just CMS text:
// sanity/schemaTypes/index.ts
export const schemaTypes = [hobbyCategory, collectibleItem, collection, priceHistory, wishlist]
| Type | Why it exists | Key fields |
|---|---|---|
| hobbyCategory | Taxonomy + scraping hints |
title, slug, icon (🏎️🤖⌨️), marketplaceKeywords for future scraper |
| collectibleItem | Core asset |
title, slug, category(ref), brand, year, sku, condition(enum: mint_sealed/mint_loose/used_excellent/used_good/damaged), rarity(enum: common/uncommon/rare/grail), images, purchasePrice, purchaseDate, currentValue (override median), quantity, status(owned/wishlist/sold), tags, notes, sourceUrl
|
| collection | Curated showcase |
title, owner, category(ref), items(ref[]), isPublic, coverImage
|
| priceHistory | Market intelligence |
item(ref), source(tokopedia/shopee/bukalapak/ebay/manual), price, currency, recordedAt, conditionAtSource, isOutlier (excluded via isOutlier != true), orderings by recordedAt desc
|
| wishlist | Hunt + alerts |
title, category(ref), targetPrice, priority(low/medium/high/grail), alertActive, notes, referenceUrl
|
Relations matter: collectibleItem.category -> hobbyCategory, priceHistory.item -> collectibleItem, collection.items -> collectibleItem[], wishlist.category -> hobbyCategory. Validations (required, min(0)), initialValue, preview, and orderings are set — judges can verify in Studio.
Config in sanity.config.ts:
defineConfig({
projectId: "71m89sy5",
dataset: "production",
basePath: "/studio",
plugins: [structureTool(), visionTool()],
})
GROQ — Only Works Because It’s Structured
// sanity/lib/queries.ts — Portfolio stats
{
"totalItems": count(*[_type=="collectibleItem" && status=="owned"]),
"totalPurchase": math::sum(*[_type=="collectibleItem" && status=="owned"].purchasePrice),
"totalMarket": math::sum(*[_type=="collectibleItem" && status=="owned"].currentValue),
"wishlistCount": count(*[_type=="wishlist"]),
"grails": *[_type=="collectibleItem" && rarity=="grail" && status=="owned"]{title, currentValue}
}
// Items with gain + 5 last prices (outlier-aware)
*[_type=="collectibleItem" && status=="owned"] | order(currentValue desc){
title, purchasePrice, currentValue,
"gain": currentValue - purchasePrice,
"gainPercent": round(((currentValue - purchasePrice)/purchasePrice)*100),
"priceHistory": *[_type=="priceHistory" && item._ref==^._id && isOutlier != true]
| order(recordedAt desc)[0..5]{price, recordedAt, source}
}
Try on prod: https://71m89sy5.apicdn.sanity.io/v2024-01-01/data/query/production?query=*[_type=="collectibleItem"]{title,currentValue} — returns 3 docs, not mock.
Build Process — Honest Writeup
Vibe-coded? Yes, but not blindly. Started with npm create sanity@latest / create-next-app prompt from Sanity’s “niche-collector” starter (Project 71m89sy5, production, monorepo studio + web). I kept the Studio embedded in Next.js at app/studio/[[...tool]] (NextStudio) instead of standalone — easier for Vercel single deployment, judged as “custom app on top of content”.
Where Sanity features were used deep:
- Custom schemas with references + enums (not a blog template)
- Vision for GROQ debugging
-
basePath: '/studio'+ CORS for embedded Studio (had to addlocalhost:3000+ Vercel domains vianpx sanity cors add --credentials) -
math::sumnotsum— learned via CLI errorUndefined function sum -
imageUrlBuilderready for futureimages[0]thumbnails
Rough edges I hit and fixed:
-
npm EACCES mkdir ~/.npm/_cacache→ usednpm_config_cache=/tmp/npm-cache(macOS root-owned cache bug) -
Tool not found: studio→ missingbasePathinsanity.config.ts, fixed + restart dev - Hydration mismatch
data-new-gr-c-s-check-loaded(Grammarly) → addedsuppressHydrationWarninginapp/layout.tsx -
swrdefault import error with Sanity + Next 16 Turbopack → wrapped Studio in'use client'Studio.tsx+ pinnedswr@2.3.7 -
No Output Directory diston Vercel → setvercel.json {framework: "nextjs", outputDirectory: ".next"}(initialvercel linkdetected no framework) - Canonical mismatch
niche-collector.vercel.appvssanity-challenge.vercel.app→ setNEXT_PUBLIC_SITE_URL+ redeploy
What I didn’t do (and why): No App SDK custom app (would duplicate Studio for this scope) and no Workflows — portfolio doesn’t need approval flows. A polished blog template would have scored lower on “thoughtfulness of schema”, so I kept the data model niche.
Stack: Next.js 16 (App Router, Turbopack) + Tailwind 4 + Sanity 6 + next-sanity 13, edge OG 1200×630, sitemap/robots, JSON-LD WebApplication, MIT license.
How to Run Locally
git clone https://github.com/dnysaz/niche-collector.git
cd niche-collector
npm_config_cache=/tmp/npm-cache npm install
cp .env.local.example .env.local
# NEXT_PUBLIC_SANITY_PROJECT_ID=71m89sy5
# NEXT_PUBLIC_SANITY_DATASET=production
npm_config_cache=/tmp/npm-cache npx sanity login --provider google
npm_config_cache=/tmp/npm-cache npx sanity dataset import sanity/seed.ndjson production -p 71m89sy5
npm_config_cache=/tmp/npm-cache npx sanity cors add http://localhost:3000 --credentials
npm_config_cache=/tmp/npm-cache npm run dev # http://localhost:3000/studio + /collection
npm run build # Route: / , /collection (1m), /studio, /opengraph-image (ƒ)
Seed has 14 docs — open Studio, edit currentValue of Camaro, watch /collection recompute gain.
Submission Checklist
- [x] Sanity Project ID:
71m89sy5(in post +sanity.config.ts+.env.local) - [x] Dataset:
production(public query URL above) - [x] Live demo: https://sanity-challenge.vercel.app
- [x] Studio: https://sanity-challenge.vercel.app/studio (no login required for view, CORS enabled)
- [x] GitHub: https://github.com/dnysaz/niche-collector (MIT)
- [x] Honest writeup above (not 3 sentences)
- [x] Tag:
#sanitychallenge
If I Had More Time
- Scraper cron (Vercel Cron) that hits Tokopedia/Shopee search via
marketplaceKeywords→ createspriceHistorydocs →currentValue = median(last 7 non-outlier) - Wishlist alert email when
min(priceHistory.price) <= targetPrice -
App SDKcustom dashboard for collection showcase (filter byisPublic)
Thanks for reading — happy collecting! 🏎️🤖⌨️
Top comments (0)