Turnkey Blockchain Explorer Implementation
Imagine: you launch a new blockchain or sidechain. Users want to track their transactions, but without an explorer it's impossible. Building such a service from scratch is non-trivial: you need to sync an archive node, index every block, implement fast search by hashes and addresses. We've done this for 8 projects and know all the pitfalls: from reorgs to performance degradation as the chain grows. Once a startup came to us—their explorer based on The Graph crashed under 50 requests per second; we had to rewrite it entirely.
In this article, I'll show how our approach works: an indexer on PostgreSQL, an API on Next.js, and smart contract decoding. You'll learn how to avoid typical mistakes and cut development time. You'll also understand why investing in a custom solution pays off within six months of active operation. We have implemented 8 blockchain explorers for various EVM networks, including a couple of sidechains with custom features.
System Components
Blockchain node (archive)
|
├── RPC/WebSocket (eth_getBlock, eth_getTransaction...)
|
Indexer (custom or The Graph)
| Reads blocks → parses transactions → stores
|
PostgreSQL + Redis (cache)
|
REST API / GraphQL
|
Frontend (Next.js)
├── Search (tx hash, address, block)
├── Block list
├── Transaction details
├── Address profile (balance + history)
└── Smart contract call decoding
For correct operation, an archive node is required. Learn more in Ethereum development docs.
How to Ensure Minimal Response Time for Hash Search?
The key is proper database indexing and caching. We use PostgreSQL with indexes on hash and block_number, and Redis for hot data (recent blocks, popular addresses). A typical mistake is hitting the node on every search. We always interpose a caching layer; otherwise, TTFB can exceed 2 seconds.
Under a load of 100+ requests per second, our solution maintains an average response time below 200 ms. This is achieved through pre-warming the cache and paginating block queries.
Why a Custom Indexer Is Better Than Off-the-Shelf Solutions?
Ready-made indexers (The Graph, Moralis) are convenient for starting out, but impose limitations: dependency on external servers, inability to fine-tune. Comparison:
| Characteristic | Ready-made Indexer | Custom Indexer |
|---|---|---|
| Control | Low – only subgraph | Full – your own logic |
| Speed | Depends on indexer network | Optimized for your data |
| Cost | Free (self-host) or paid | One-time development + hosting |
| Flexibility | Limited (GraphQL schema) | Any fields and relations |
| Learning curve | Exists | Need a developer |
For production with high load (over 100 requests/sec), we always recommend a custom indexer. It delivers predictable performance and freedom to extend. According to our data, switching from a ready-made to a custom solution reduces infrastructure costs by 30–40%.
How to Index Blocks: Three Steps
- Node setup and connection. Deploy an archive node (Geth/Nethermind) or use a WebSocket provider. Ensure the node is ready to return full blocks with transactions.
-
Implement the indexer. Using the
viemlibrary, subscribe to new blocks and parse them into PostgreSQL. It's important to handle reorgs—store the previous block hash and recheck on fork. - Post-processing. After inserting transactions, update address statistics (balances, transaction count) and place hot data into Redis.
import { createPublicClient, webSocket } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: webSocket(process.env.ETH_WS_URL)
});
class BlockIndexer {
async indexBlock(blockNumber: bigint): Promise<void> {
const block = await client.getBlock({
blockNumber,
includeTransactions: true
});
await db.transaction(async (trx) => {
await trx('blocks').insert({
number: Number(block.number),
hash: block.hash,
parent_hash: block.parentHash,
timestamp: new Date(Number(block.timestamp) * 1000),
miner: block.miner,
gas_used: block.gasUsed.toString(),
gas_limit: block.gasLimit.toString(),
transaction_count: block.transactions.length,
base_fee_per_gas: block.baseFeePerGas?.toString() ?? null
});
for (const tx of block.transactions) {
await trx('transactions').insert({
hash: tx.hash,
block_number: Number(tx.blockNumber),
from_address: tx.from.toLowerCase(),
to_address: tx.to?.toLowerCase() ?? null,
value: tx.value.toString(),
gas: tx.gas.toString(),
gas_price: tx.gasPrice?.toString() ?? null,
max_fee_per_gas: tx.maxFeePerGas?.toString() ?? null,
input: tx.input,
nonce: tx.nonce,
transaction_index: tx.transactionIndex
});
await this.updateAddressStats(trx, tx.from.toLowerCase());
if (tx.to) await this.updateAddressStats(trx, tx.to.toLowerCase());
}
});
}
async watchNewBlocks(): Promise<void> {
const unwatch = client.watchBlocks({
onBlock: async (block) => {
await this.indexBlock(block.number);
},
onError: (error) => {
logger.error('Block watch error', error);
}
});
const latestIndexed = await this.getLatestIndexedBlock();
const currentBlock = await client.getBlockNumber();
for (let i = latestIndexed + 1n; i <= currentBlock; i++) {
await this.indexBlock(i);
}
}
}
API: Search
app.get('/api/search', async (req, res) => {
const query = req.query.q as string;
if (!query) return res.status(400).json({ error: 'Query required' });
if (/^0x[0-9a-f]{64}$/i.test(query)) {
const tx = await db('transactions').where('hash', query.toLowerCase()).first();
if (tx) return res.json({ type: 'transaction', data: tx });
const block = await db('blocks').where('hash', query.toLowerCase()).first();
if (block) return res.json({ type: 'block', data: block });
} else if (/^0x[0-9a-f]{40}$/i.test(query)) {
return res.json({ type: 'address', address: query.toLowerCase() });
} else if (/^\d+$/.test(query)) {
const block = await db('blocks').where('number', parseInt(query)).first();
if (block) return res.json({ type: 'block', data: block });
}
res.json({ type: 'not_found' });
});
Frontend: Decoding Input Data
import { decodeFunctionData } from 'viem';
async function decodeTransactionInput(
input: string,
contractAddress: string
): Promise<DecodedInput | null> {
if (input === '0x') return null;
const abi = await getContractAbi(contractAddress);
if (!abi) return { raw: input };
try {
const decoded = decodeFunctionData({ abi, data: input as `0x${string}` });
return {
functionName: decoded.functionName,
args: decoded.args,
raw: input
};
} catch {
return { raw: input };
}
}
What's Included in Explorer Development
| Component | Result |
|---|---|
| Architecture documentation | ER diagram, API specification, data flow description |
| Indexer | Source code in TypeScript (or Python), Docker container for deployment |
| Backend | REST/GraphQL API with caching (Redis), OpenAPI documentation |
| Frontend | Next.js application with search, block list, address profile, and decoding |
| Testing | Load tests (k6) and performance report |
| Team documentation | Instructions for startup, configuration, and monitoring |
| Technical support | 2 weeks after launch: bug fixes and adaptation help |
Work Process
We divide the project into five stages:
| Stage | Duration | Result |
|---|---|---|
| Analytics | 2-3 days | Architecture document |
| Design | 3-5 days | DB schema, API, mockups |
| Implementation | 2-4 weeks | Working indexer + API |
| Testing | 5-7 days | Load test report |
| Deployment and monitoring | 2-3 days | Production launch |
During the analytics stage, we clarify what data you need: only blocks and transactions, or a full picture with internal calls and event logs. Design includes preparing a database schema (usually about 15–20 tables) and an API specification in OpenAPI format.
Approximate Timelines
- MVP explorer (transactions, blocks, addresses) without indexer (via RPC) – 2–3 weeks.
- Full indexer with PostgreSQL + API + Frontend – 6–10 weeks.
- Explorer for a custom EVM network – from 2 months.
Infrastructure costs depend on network size and are discussed individually. Contact us to receive a preliminary estimate for your project.
Typical Mistakes When Building a Blockchain Explorer
- Missing backfill after indexer failure – data is lost. Solution: store the last processed block in the DB and resume from there on restart.
-
Ignoring reorgs – blocks can change. Subscribe to
blockevents via WebSocket and check hashes. - Scanning all blocks via RPC without pagination – node overload. Use batch loading with delays.
If you've encountered any of these issues or want to avoid them from the start, request a consultation—we'll help you design a stable explorer.
What's Next?
A custom indexer is an investment in performance and reliability. No dependence on external services: your data is always under control. Order development—we'll propose an architecture for your network within one business day.







