# Niche Collector — Collection Tracker + Price Intelligence

> Source: <https://dev.to/ketutdana/niche-collector-collection-tracker-price-intelligence-444d>
> Published: 2026-09-22 05:05:36+00:00

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