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 (1)

Collapse
 
launchgatecheck profile image
Launch Gate •

Excluding flagged outliers is a good start; I would also show the sample size and age of the remaining price observations beside each median. A median from two listings last seen six months ago is technically correct but carries very different confidence than twenty current listings. That small provenance cue would make the valuation much easier to trust.