DEV Community

Ketut Dana
Ketut Dana

Posted on

Niche Collector — Collection Tracker + Price Intelligence

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

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

  1. / — landing explains schema + shows GROQ example
  2. /collection — live portfolio:
    • TOTAL MARKET Rp 3,900,000 vs TOTAL 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
  3. /studio — Sanity Studio (embedded NextStudio, basePath: '/studio', Vision enabled). Try Vision:
   *[_type=="priceHistory" && isOutlier != true] | order(recordedAt desc){price, source, "item": item->title}
Enter fullscreen mode Exit fullscreen mode
  1. SEO goodies: /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).


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]
Enter fullscreen mode Exit fullscreen mode
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()],
})
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

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 add 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
  • Hydration mismatch 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)
  • Canonical mismatch 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.


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 (ƒ)
Enter fullscreen mode Exit fullscreen mode

Seed has 14 docs — open Studio, edit currentValue of Camaro, watch /collection recompute gain.


Submission Checklist


If I Had More Time

  • Scraper cron (Vercel Cron) that hits Tokopedia/Shopee search via marketplaceKeywords → creates priceHistory docs → currentValue = median(last 7 non-outlier)
  • Wishlist alert email when min(priceHistory.price) <= targetPrice
  • App SDK custom dashboard for collection showcase (filter by isPublic)

Thanks for reading — happy collecting! 🏎️🤖⌨️

Top comments (0)