I build Web3 product surfaces in Next.js. The chain is the write path. Indexers and APIs are the read path. The UI should not invent a third state model.
The principles I keep on those apps: put shareable state in the URL, collocate by feature, one data subscription per route, cache reads through React Query, talk to the EVM through viem and wagmi, and delay abstractions until the third copy.

Collocation
I organize by feature, not by file type. The dashboard's hook, types, tests, and pieces sit in the dashboard folder. I do not keep a global hooks/ dumping ground for something only one route uses.
That is the same colocation Kent C. Dodds describes in Colocation. Maintainability is the point: a feature moves or dies as a unit. Scalability is a side effect. I am not hunting four directories to change a filter.
State in the URL
Filters, tabs, chain ids, and other view state that a user might refresh or paste to a teammate go in the query string. Next.js already owns routing. Fighting it with a parallel client store means the back button lies.
Encoded in the URL, the view survives reload. A link is a deep link. Canonical URLs also keep search engines from indexing six copies of the same page with different client-only state.
Local component state still exists: hover, input drafts, whether a modal is open. I do not put those in the URL.
One subscription per route
Whether the backend is GraphQL subscriptions or REST polling, I want one stream shaped for the page, not five sockets that each hydrate a widget.
Fewer connections means less overhead and less glue. The route owns the query. Children read from cache. When the page unmounts, the subscription goes with it.
This is the same idea as "load the page's data in the page," applied to live chain and indexer feeds.
Cached reads
Identical reads share a query key. React Query (TanStack Query) already does this: cache, dedupe in-flight requests, refetch on focus and reconnect, share { data, isLoading, error } across components.
I keep keys boring and complete: ['vault', chainId, address], not 'vault' plus hidden closure state. App-wide data can be prefetched at the root. Everything else stays next to the feature.
On chain, wagmi is React Query with Ethereum types. A useReadContract is the same mental model as useQuery against an indexer. viem is the client underneath: actions, encoding, transports. I do not wrap a second cache around it.
Writes still go through the wallet: user signs, we wait for the receipt, we invalidate the query keys that just went stale. Reads stay queries. Writes stay mutations. Mixing them in a custom store is how the UI desyncs from the block.
AHA instead of a framework-in-the-app
I copy twice before I extract. A generic useWeb3Resource that takes twenty options is usually worse than two hooks that look alike this week.
Kent's AHA list is the one I use: avoid hasty abstractions, WET until the shape is real, optimize for change, keep complexity inlined until it has a name, make the abstraction fit this product.
Standard libraries first: Next.js, React Query, wagmi, viem. I do not add an internal SDK until three apps would actually import it.
Those rules are the architecture. Next.js renders the app. URLs hold the view. React Query and wagmi hold the reads. viem talks to the node. Features live in folders. Abstractions wait until they have earned a name.