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.
71m89sy5
production — os9xuj7u1
Submission tag: #sanitychallenge
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.
TOTAL MARKET Rp 3,900,000 vs TOTAL PURCHASE Rp 2,900,000 → +Rp 1,000,000 (34%) (computed via GROQ, not client JS)Tokopedia → Shopee
basePath: '/studio', Vision enabled). Try Vision:
*[_type=="priceHistory" && isOutlier != true] | order(recordedAt desc){price, source, "item": item->title}
/opengraph-image (1200×630 edge), /icon.svg, /sitemap.xml, /robots.txt, JSON-LD WebApplication
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).
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 viaisOutlier != true ), orderings byrecordedAt 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()],
})
// 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.
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:
localhost:3000 + Vercel domains via npx sanity cors add --credentials) math::sum not sum — learned via CLI error Undefined function sum
imageUrlBuilder ready for future images[0] thumbnails
Rough edges I hit and fixed:
npm EACCES mkdir ~/.npm/_cacache → used npm_config_cache=/tmp/npm-cache (macOS root-owned cache bug)Tool not found: studio → missing basePath in sanity.config.ts, fixed + restart dev data-new-gr-c-s-check-loaded (Grammarly) → added suppressHydrationWarning in app/layout.tsx
swr default import error with Sanity + Next 16 Turbopack → wrapped Studio in 'use client' Studio.tsx + pinned swr@2.3.7
No Output Directory dist on Vercel → set vercel.json {framework: "nextjs", outputDirectory: ".next"} (initial vercel link detected no framework)niche-collector.vercel.app vs sanity-challenge.vercel.app → set NEXT_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.
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
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.
.env.local)#sanitychallenge
marketplaceKeywords → creates priceHistory docs → currentValue = median(last 7 non-outlier)
min(priceHistory.price) <= targetPrice
App SDK custom dashboard for collection showcase (filter by isPublic)
Thanks for reading — happy collecting! 🏎️🤖⌨️