{"slug": "niche-collector-collection-tracker-price-intelligence", "title": "Niche Collector — Collection Tracker + Price Intelligence", "summary": "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.", "body_md": "**Sanity Challenge — Path 2: Vibe-code something strange**\n\nPortfolio 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.\n\n`71m89sy5`\n`production` — `os9xuj7u1`\nSubmission tag: `#sanitychallenge`\n\n**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.\n\nIt’s deliberately boring in the best way: no AI chat, just structured data doing work keyword search can’t.\n\n**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.\n\n`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`\n`basePath: '/studio'`, Vision enabled). Try Vision:\n\n```\n   *[_type==\"priceHistory\" && isOutlier != true] | order(recordedAt desc){price, source, \"item\": item->title}\n```\n\n`/opengraph-image` (1200×630 edge), `/icon.svg`, `/sitemap.xml`, `/robots.txt`, JSON-LD `WebApplication`\nIf `NEXT_PUBLIC_SANITY_PROJECT_ID` is missing, `/collection` falls back to mock — but on Vercel it’s live against `71m89sy5/production` (14 seeded docs).\n\n5 types in `sanity/schemaTypes/` — designed for portfolio math, not just CMS text:\n\n``` js\n// sanity/schemaTypes/index.ts\nexport const schemaTypes = [hobbyCategory, collectibleItem, collection, priceHistory, wishlist]\n```\n\n| Type | Why it exists | Key fields | \n|---|---|---|\n| **hobbyCategory** | Taxonomy + scraping hints | `title` ,`slug` ,`icon` (🏎️🤖⌨️),`marketplaceKeywords` for future scraper | \n| **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` | \n| **collection** | Curated showcase | `title` ,`owner` ,`category(ref)` ,`items(ref[])` ,`isPublic` ,`coverImage` | \n| **priceHistory** | Market intelligence | `item(ref)` ,`source(tokopedia/shopee/bukalapak/ebay/manual)` ,`price` ,`currency` ,`recordedAt` ,`conditionAtSource` ,`isOutlier` (excluded via`isOutlier != true` ), orderings by`recordedAt desc` | \n| **wishlist** | Hunt + alerts | `title` ,`category(ref)` ,`targetPrice` ,`priority(low/medium/high/grail)` ,`alertActive` ,`notes` ,`referenceUrl` | \n\nRelations 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.\n\nConfig in `sanity.config.ts`:\n\n```\ndefineConfig({\n  projectId: \"71m89sy5\",\n  dataset: \"production\",\n  basePath: \"/studio\",\n  plugins: [structureTool(), visionTool()],\n})\n// sanity/lib/queries.ts — Portfolio stats\n{\n  \"totalItems\": count(*[_type==\"collectibleItem\" && status==\"owned\"]),\n  \"totalPurchase\": math::sum(*[_type==\"collectibleItem\" && status==\"owned\"].purchasePrice),\n  \"totalMarket\": math::sum(*[_type==\"collectibleItem\" && status==\"owned\"].currentValue),\n  \"wishlistCount\": count(*[_type==\"wishlist\"]),\n  \"grails\": *[_type==\"collectibleItem\" && rarity==\"grail\" && status==\"owned\"]{title, currentValue}\n}\n\n// Items with gain + 5 last prices (outlier-aware)\n*[_type==\"collectibleItem\" && status==\"owned\"] | order(currentValue desc){\n  title, purchasePrice, currentValue,\n  \"gain\": currentValue - purchasePrice,\n  \"gainPercent\": round(((currentValue - purchasePrice)/purchasePrice)*100),\n  \"priceHistory\": *[_type==\"priceHistory\" && item._ref==^._id && isOutlier != true]\n    | order(recordedAt desc)[0..5]{price, recordedAt, source}\n}\n```\n\nTry on prod: `https://71m89sy5.apicdn.sanity.io/v2024-01-01/data/query/production?query=*[_type==\"collectibleItem\"]{title,currentValue}` — returns 3 docs, not mock.\n\n**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”.\n\n**Where Sanity features were used deep:**\n\n`localhost:3000` + Vercel domains via `npx sanity cors add --credentials`)` math::sum` not `sum` — learned via CLI error `Undefined function sum`\n`imageUrlBuilder` ready for future `images[0]` thumbnails\n**Rough edges I hit and fixed:**\n\n`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`\n`swr` default import error with Sanity + Next 16 Turbopack → wrapped Studio in `'use client'` `Studio.tsx` + pinned `swr@2.3.7`\n`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\n**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.\n\n**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.\n\n```\ngit clone https://github.com/dnysaz/niche-collector.git\ncd niche-collector\n\nnpm_config_cache=/tmp/npm-cache npm install\ncp .env.local.example .env.local\n# NEXT_PUBLIC_SANITY_PROJECT_ID=71m89sy5\n# NEXT_PUBLIC_SANITY_DATASET=production\n\nnpm_config_cache=/tmp/npm-cache npx sanity login --provider google\nnpm_config_cache=/tmp/npm-cache npx sanity dataset import sanity/seed.ndjson production -p 71m89sy5\nnpm_config_cache=/tmp/npm-cache npx sanity cors add http://localhost:3000 --credentials\n\nnpm_config_cache=/tmp/npm-cache npm run dev # http://localhost:3000/studio + /collection\nnpm run build # Route: / , /collection (1m), /studio, /opengraph-image (ƒ)\n```\n\nSeed has 14 docs — open Studio, edit `currentValue` of Camaro, watch `/collection` recompute gain.\n\n`.env.local`)`#sanitychallenge`\n`marketplaceKeywords` → creates `priceHistory` docs → `currentValue = median(last 7 non-outlier)`\n`min(priceHistory.price) <= targetPrice`\n`App SDK` custom dashboard for collection showcase (filter by `isPublic`)\nThanks for reading — happy collecting! 🏎️🤖⌨️", "url": "https://wpnews.pro/news/niche-collector-collection-tracker-price-intelligence", "canonical_source": "https://dev.to/ketutdana/niche-collector-collection-tracker-price-intelligence-444d", "published_at": "2026-09-22 05:05:36+00:00", "updated_at": "2026-09-22 05:22:45.358761+00:00", "lang": "en", "topics": ["structured-data", "developer-tools"], "entities": ["Sanity", "Niche Collector", "GROQ", "Tokopedia", "Shopee", "Vercel", "Bukalapak", "eBay"], "alternates": {"html": "https://wpnews.pro/news/niche-collector-collection-tracker-price-intelligence", "markdown": "https://wpnews.pro/news/niche-collector-collection-tracker-price-intelligence.md", "text": "https://wpnews.pro/news/niche-collector-collection-tracker-price-intelligence.txt", "jsonld": "https://wpnews.pro/news/niche-collector-collection-tracker-price-intelligence.jsonld"}}