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 (1)
Excluding flagged outliers is a good start; I would also show the sample size and age of the remaining price observations beside each median. A median from two listings last seen six months ago is technically correct but carries very different confidence than twenty current listings. That small provenance cue would make the valuation much easier to trust.