cd /news/structured-data/niche-collector-collection-tracker-p… · home topics structured-data article
[ARTICLE · art-136678] src=dev.to ↗ pub= topic=structured-data verified=true sentiment=↑ positive

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.

by read4 min views1 publishedSep 22, 2026

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 productionos9xuj7u1 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! 🏎️🤖⌨️

── more in #structured-data 4 stories · sorted by recency
── more on @sanity 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/niche-collector-coll…] indexed:0 read:4min 2026-09-22 ·