Niche Collector — Collection Tracker + Price Intelligence A developer built Niche Collector, a portfolio tracker for hobbyists collecting Hot Wheels, Gunpla, and mechanical keyboards that computes gains and losses entirely through GROQ aggregations on Sanity structured content. The app pulls price history from Tokopedia and Shopee listings and flags scam listings as outliers so they are excluded from median valuations, a task the developer says full-text keyword search cannot perform. 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: js // 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 , } // 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 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. .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 🏎️🤖⌨️