Problem
The client wanted a dashboard website to display SmartNFT creations, transfers, and deletions in real time (you can think of a SmartNFT as a kind of ticket). On top of the live feed, any visitor needed a panoramic view of the whole ecosystem: historical data, token price, total staked, and aggregate values derived from all of the above.
First Thoughts
The first design priority was a client-side architecture that could serve any queued data to the client at very low latency. A CDN was the answer, and Vercel's CDN covered it out of the box - including operating outside of Europe.
Beyond the CDN, where the backend lived (and how close it sat to the database) mattered just as much. Vercel solved that too, since an admin can choose where to host the NextJS functions. Even at multiple places at the same time.
For the database, we initially reached for TimescaleDB, since it's purpose-built for exactly this kind of work - fast aggregation of time-series data via its hypertables. The problem was cost: a managed version, especially with the read replicas we'd need, was completely outside any reasonable budget. For building the MVP, though, we went with it anyway, so that we could be certain database performance wouldn't be a bottleneck. We revisited this choice once the MVP reached a ready-to-launch state.
We also needed a scraper that could quickly share on-chain findings with other propagation servers. For that we chose Bun, both for performance and for the ease of implementing this last piece.
Finally, for the replication / streaming servers, we opted for VPS instances given the expected volume. Any other option was simply too time-consuming to justify.
The final architecture can be summarized as:
- Scraper - checks data on-chain and sends ready-to-view data to a master server.
- Master server - can either act as a replication server itself or be configured to share data only with other replication servers (for scalability).
- Replication server - serves clients with real-time data over Server-Sent Events (SSE).
- Backend - a small backend to handle caching, cache invalidation, and queries to external services like CoinGecko.
- Client - the full interface, built with Next.js, Tailwind CSS, and TypeScript. This combination let us move efficiently without wasting time on boilerplate.
Modus Operandi
With the design phase done, the rest was implementation, which we tackled in the order listed above.
We started by building the Bun scraper and hosting it on a low-to-medium-sized VPS on Hetzner. Here we hit the first issue: the data wasn't clean at all. It carried several inconsistencies - broken timestamps, empty contents, and so on - and given the volume we were about to fetch, this was extremely time-consuming to handle. Especially since for the dashboard consistency was one of the key factors. Meaning that simply discarding broken transactions wasn't an option and we had to go trough each error manually, instead.
The next major issue was rate limits. We couldn't just "scrape data from the chain", so we had to purchase services that let us query chain data in a developer-friendly, efficient way (Infura, for example). For other tasks we could rely on blockchain scanners (BlockScan), but the rate limits there were heavy as well.
The scraper had a genuinely difficult job: a huge volume of data to fetch (more than 3M transactions that had to be enriched, resulting in tens of millions of total calls), under heavy rate limits, and as fast as possible. We went through several iterations to land on a good tradeoff between these constraints. The final scraper could fetch every transaction in just under a week. The key design aspects were incremental error handling - making sure that if the scraper crashed, the data stayed consistent - while everything else came down to tuning intervals and batch sizes.
Fortunately, the rest was easier. The replication servers were just a few lines of code.
The hardest part of the whole backend was writing efficient SQL, since we aimed for sub-100ms replies on the hot path. That meant we couldn't lean on an ORM, and SQL query optimization ended up taking a meaningful chunk of time. Especially the aggregation side.
The frontend was not a pain point, thanks to the chosen stack. With any other set of technologies, the implementation time for these last two layers would have been far longer - we're talking at least 2–3×. One lucky break: as we neared the MVP release, Next.js shipped experimental support for caching components, backend queries, and more. All of that is now stable.
Changes
There was one major change to our initial decisions: the database. We started on Timescale and then moved to Supabase. The main reason was cost. Supabase came in at less than half the price of Timescale, on better hardware. And while 3M logs is a lot, it's still easily handled by any vanilla Postgres implementation with extensive index coverage.
This also meant changing the backend. Supabase offered geo-routed queries, but only through its SDK, so we migrated onto that.
With all of this, the project was ready.
Timeline
Development started in November 2024. The scraper was the single feature that consumed the most time, going through two major releases alongside two major database schema updates.
- Scraper V1 - released March 2025
- Scraper V2 - released May 2025
- Public launch - June 1, 2025
The frontend and everything else were developed in parallel.
Team
The whole project was realized before NFW Web Studio was even a thing. It was realized by a single member of the team. While design was provided by an external entity.
Numbers
Lighthouse / Speed Insights
| Metric | Score |
|---|---|
| Performance | 28 |
| Accessibility | 100 |
| Best Practices | 100 |
| SEO | 100 |
| Agentic Browsing | 1/2 |
A note on that Performance score: the project was released in spring 2025, actively developed through November 2025, and kept under normal maintenance until early 2026. After that, maintenance ended and the client shipped a new dashboard with a rebrand. The older version currently runs on a heavily under-dimensioned, single-located database. The accessibility, best-practices, and SEO scores reflect how the application was actually built; the performance figure largely reflects the post-handoff infrastructure rather than the original architecture. Heavily relying on Suspense boundaries would give a bump to the benchmark score. Although, for the nature of the project, it's the data that matters, not the UI, it's the data that brings value to the dashboard and not the interface around it.
Query analysis
Excluding the scraper's bulk inserts, the database tells a clear story: the queries on the critical render path are fast, and the heavy ones are deliberately kept off it.
Hot path (the queries that drive the UX):
- Live event feed (
get_smartnft_events) - ~141,000 calls at ~16.6 ms mean, with a ~99% cache hit rate. This is the real-time feed that powers the dashboard, and it's quick. - Single SmartNFT lookup (
get_smartnft) - ~38,800 calls at ~9 ms mean, ~95% cache hit. Detail views resolve almost instantly. - Address listing - ~172,600 calls at ~44 ms mean, ~97% cache hit. Comfortably within budget for a paginated list.
These are the queries a visitor actually waits on, and they hold to the sub-100ms target the backend was designed around.
Heavy aggregates (deliberately off the critical path):
A handful of analytical RPCs are genuinely expensive - for example, the publisher summary (get_smartnft_publishers_summary, ~3.2 s mean) and the wallet-balance computation (get_wallet_balances, ~1.4 s mean, which sums over ~1.4M transfers on each call). These were an accepted tradeoff rather than an oversight: they run at low call volume, sit behind caching, and never block the initial render. The obvious next step - pre-aggregating them into materialized views - is exactly the kind of optimization that was on the roadmap when active development ended. It's also the kind of work that stopped once the project was handed off, which (together with the single-located database now backing it) is what makes the live numbers look worse today than the architecture warrants.
In short: the design got the latency-sensitive paths right, treated the expensive aggregates as a conscious, contained tradeoff, and left a clear, well-understood path for the next round of optimization.
