How to Build a Stellar Blockchain Explorer Using Horizon API (Step-by-Step Guide)
Building a blockchain explorer is one of the best ways to understand how Stellar works under the hood. In this comprehensive guide, we'll build a fully functional Stellar blockchain explorer using the Horizon API and LumenQuery infrastructure.
What We're Building
By the end of this tutorial, you'll have a working blockchain explorer that can:
Prerequisites
Before we start, you'll need:
Setting Up the Project
Let's start with a fresh Next.js project:
npx create-next-app@latest stellar-explorer --typescript
cd stellar-explorer
npm installSet your LumenQuery API key:
# .env.local
LUMENQUERY_API_KEY=lq_your_api_key_here
NEXT_PUBLIC_HORIZON_URL=https://api.lumenquery.ioCreating the Horizon Client
First, let's create a reusable client for interacting with the Horizon API:
// lib/horizon.ts
const HORIZON_URL = process.env.NEXT_PUBLIC_HORIZON_URL || 'https://api.lumenquery.io';
const API_KEY = process.env.LUMENQUERY_API_KEY;
async function horizonRequest(endpoint: string) {
const response = await fetch(`${HORIZON_URL}${endpoint}`, {
headers: {
'X-API-Key': API_KEY || '',
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Horizon API error: ${response.status}`);
}
return response.json();
}
export { horizonRequest, HORIZON_URL };Fetching Recent Transactions
The transactions endpoint returns the most recent transactions on the network:
// lib/transactions.ts
import { horizonRequest } from './horizon';
interface Transaction {
id: string;
hash: string;
ledger: number;
created_at: string;
source_account: string;
fee_charged: string;
operation_count: number;
successful: boolean;
}
interface TransactionsResponse {
_embedded: {
records: Transaction[];
};
_links: {
next: { href: string };
prev: { href: string };
};
}
export async function getRecentTransactions(limit = 20): Promise<Transaction[]> {
const data: TransactionsResponse = await horizonRequest(
`/transactions?limit=${limit}&order=desc`
);
return data._embedded.records;
}
export async function getTransactionByHash(hash: string): Promise<Transaction> {
return horizonRequest(`/transactions/${hash}`);
}Displaying Transactions in React
// components/TransactionList.tsx
'use client';
import { useEffect, useState } from 'react';
import { getRecentTransactions } from '@/lib/transactions';
export function TransactionList() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchTransactions() {
try {
const txs = await getRecentTransactions(20);
setTransactions(txs);
} catch (error) {
console.error('Failed to fetch transactions:', error);
} finally {
setLoading(false);
}
}
fetchTransactions();
}, []);
if (loading) return <div>Loading transactions...</div>;
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Recent Transactions</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2">Hash</th>
<th className="text-left py-2">Ledger</th>
<th className="text-left py-2">Operations</th>
<th className="text-left py-2">Status</th>
<th className="text-left py-2">Time</th>
</tr>
</thead>
<tbody>
{transactions.map((tx) => (
<tr key={tx.id} className="border-b hover:bg-gray-50">
<td className="py-2 font-mono text-sm">
{tx.hash.slice(0, 8)}...{tx.hash.slice(-8)}
</td>
<td className="py-2">{tx.ledger}</td>
<td className="py-2">{tx.operation_count}</td>
<td className="py-2">
<span className={`px-2 py-1 rounded text-sm ${
tx.successful ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{tx.successful ? 'Success' : 'Failed'}
</span>
</td>
<td className="py-2 text-sm text-gray-600">
{new Date(tx.created_at).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}Querying Account Information
Every Stellar account has balances, trustlines, and other important data:
// lib/accounts.ts
import { horizonRequest } from './horizon';
interface Balance {
balance: string;
asset_type: string;
asset_code?: string;
asset_issuer?: string;
}
interface Account {
id: string;
account_id: string;
sequence: string;
balances: Balance[];
num_subentries: number;
thresholds: {
low_threshold: number;
med_threshold: number;
high_threshold: number;
};
flags: {
auth_required: boolean;
auth_revocable: boolean;
auth_immutable: boolean;
auth_clawback_enabled: boolean;
};
}
export async function getAccount(accountId: string): Promise<Account> {
return horizonRequest(`/accounts/${accountId}`);
}
export async function getAccountTransactions(
accountId: string,
limit = 20
): Promise<Transaction[]> {
const data = await horizonRequest(
`/accounts/${accountId}/transactions?limit=${limit}&order=desc`
);
return data._embedded.records;
}
export async function getAccountOperations(
accountId: string,
limit = 20
) {
const data = await horizonRequest(
`/accounts/${accountId}/operations?limit=${limit}&order=desc`
);
return data._embedded.records;
}Building the Account Viewer Component
// components/AccountViewer.tsx
'use client';
import { useState } from 'react';
import { getAccount } from '@/lib/accounts';
export function AccountViewer() {
const [accountId, setAccountId] = useState('');
const [account, setAccount] = useState<Account | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSearch = async () => {
if (!accountId.startsWith('G') || accountId.length !== 56) {
setError('Invalid Stellar account ID');
return;
}
setLoading(true);
setError(null);
try {
const data = await getAccount(accountId);
setAccount(data);
} catch (err) {
setError('Account not found');
setAccount(null);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Account Lookup</h2>
<div className="flex gap-2">
<input
type="text"
placeholder="Enter Stellar Account ID (G...)"
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
className="flex-1 px-4 py-2 border rounded"
/>
<button
onClick={handleSearch}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{loading ? 'Loading...' : 'Search'}
</button>
</div>
{error && <p className="text-red-600">{error}</p>}
{account && (
<div className="p-4 border rounded">
<h3 className="text-lg font-semibold mb-4">Balances</h3>
<div className="space-y-2">
{account.balances.map((balance, i) => (
<div key={i} className="flex justify-between p-2 bg-gray-50 rounded">
<span className="font-medium">
{balance.asset_type === 'native' ? 'XLM' : balance.asset_code}
</span>
<span>{parseFloat(balance.balance).toLocaleString()}</span>
</div>
))}
</div>
<h3 className="text-lg font-semibold mt-6 mb-2">Account Flags</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>Auth Required: {account.flags.auth_required ? 'Yes' : 'No'}</div>
<div>Auth Revocable: {account.flags.auth_revocable ? 'Yes' : 'No'}</div>
<div>Auth Immutable: {account.flags.auth_immutable ? 'Yes' : 'No'}</div>
<div>Clawback: {account.flags.auth_clawback_enabled ? 'Yes' : 'No'}</div>
</div>
</div>
)}
</div>
);
}Parsing Operations
Operations are the atomic units of work on Stellar. Each transaction contains one or more operations:
// lib/operations.ts
import { horizonRequest } from './horizon';
interface Operation {
id: string;
type: string;
type_i: number;
transaction_hash: string;
source_account: string;
created_at: string;
// Type-specific fields
asset_type?: string;
asset_code?: string;
amount?: string;
from?: string;
to?: string;
starting_balance?: string;
}
const OPERATION_TYPES: Record<number, string> = {
0: 'Create Account',
1: 'Payment',
2: 'Path Payment Strict Receive',
3: 'Manage Sell Offer',
4: 'Create Passive Sell Offer',
5: 'Set Options',
6: 'Change Trust',
7: 'Allow Trust',
8: 'Account Merge',
9: 'Inflation',
10: 'Manage Data',
11: 'Bump Sequence',
12: 'Manage Buy Offer',
13: 'Path Payment Strict Send',
14: 'Create Claimable Balance',
15: 'Claim Claimable Balance',
16: 'Begin Sponsoring Future Reserves',
17: 'End Sponsoring Future Reserves',
18: 'Revoke Sponsorship',
19: 'Clawback',
20: 'Clawback Claimable Balance',
21: 'Set Trust Line Flags',
22: 'Liquidity Pool Deposit',
23: 'Liquidity Pool Withdraw',
24: 'Invoke Host Function',
25: 'Extend Footprint TTL',
26: 'Restore Footprint',
};
export function getOperationTypeName(typeI: number): string {
return OPERATION_TYPES[typeI] || 'Unknown';
}
export function parseOperationDetails(op: Operation): Record<string, string> {
const details: Record<string, string> = {
Type: getOperationTypeName(op.type_i),
Source: op.source_account,
};
switch (op.type) {
case 'payment':
details.To = op.to || '';
details.Amount = op.amount || '';
details.Asset = op.asset_type === 'native' ? 'XLM' : op.asset_code || '';
break;
case 'create_account':
details.Account = op.account || '';
details['Starting Balance'] = op.starting_balance || '';
break;
case 'change_trust':
details.Asset = op.asset_code || '';
details.Issuer = op.asset_issuer || '';
details.Limit = op.limit || '';
break;
// Add more operation types as needed
}
return details;
}Displaying Ledger Data
Ledgers are the fundamental unit of time on Stellar. Each ledger closes approximately every 5 seconds:
// lib/ledgers.ts
import { horizonRequest } from './horizon';
interface Ledger {
id: string;
sequence: number;
hash: string;
prev_hash: string;
transaction_count: number;
operation_count: number;
closed_at: string;
total_coins: string;
fee_pool: string;
base_fee_in_stroops: number;
base_reserve_in_stroops: number;
protocol_version: number;
}
export async function getLatestLedgers(limit = 10): Promise<Ledger[]> {
const data = await horizonRequest(`/ledgers?limit=${limit}&order=desc`);
return data._embedded.records;
}
export async function getLedgerBySequence(sequence: number): Promise<Ledger> {
return horizonRequest(`/ledgers/${sequence}`);
}
export async function getLedgerTransactions(sequence: number, limit = 20) {
const data = await horizonRequest(
`/ledgers/${sequence}/transactions?limit=${limit}`
);
return data._embedded.records;
}Building the Ledger Dashboard
// components/LedgerDashboard.tsx
'use client';
import { useEffect, useState } from 'react';
import { getLatestLedgers } from '@/lib/ledgers';
export function LedgerDashboard() {
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchLedgers() {
const data = await getLatestLedgers(10);
setLedgers(data);
setLoading(false);
}
fetchLedgers();
const interval = setInterval(fetchLedgers, 5000); // Refresh every 5 seconds
return () => clearInterval(interval);
}, []);
if (loading) return <div>Loading ledgers...</div>;
const latestLedger = ledgers[0];
return (
<div className="space-y-6">
{/* Network Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-blue-50 rounded">
<div className="text-sm text-gray-600">Latest Ledger</div>
<div className="text-2xl font-bold">{latestLedger?.sequence.toLocaleString()}</div>
</div>
<div className="p-4 bg-green-50 rounded">
<div className="text-sm text-gray-600">Protocol Version</div>
<div className="text-2xl font-bold">{latestLedger?.protocol_version}</div>
</div>
<div className="p-4 bg-purple-50 rounded">
<div className="text-sm text-gray-600">Base Fee</div>
<div className="text-2xl font-bold">{latestLedger?.base_fee_in_stroops} stroops</div>
</div>
<div className="p-4 bg-orange-50 rounded">
<div className="text-sm text-gray-600">Total XLM</div>
<div className="text-2xl font-bold">
{(parseFloat(latestLedger?.total_coins || '0') / 1e7).toLocaleString()}
</div>
</div>
</div>
{/* Recent Ledgers Table */}
<div>
<h3 className="text-lg font-semibold mb-4">Recent Ledgers</h3>
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2">Sequence</th>
<th className="text-left py-2">Transactions</th>
<th className="text-left py-2">Operations</th>
<th className="text-left py-2">Closed At</th>
</tr>
</thead>
<tbody>
{ledgers.map((ledger) => (
<tr key={ledger.id} className="border-b hover:bg-gray-50">
<td className="py-2 font-mono">{ledger.sequence}</td>
<td className="py-2">{ledger.transaction_count}</td>
<td className="py-2">{ledger.operation_count}</td>
<td className="py-2 text-sm text-gray-600">
{new Date(ledger.closed_at).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}Putting It All Together
Create your main explorer page:
// app/page.tsx
import { TransactionList } from '@/components/TransactionList';
import { AccountViewer } from '@/components/AccountViewer';
import { LedgerDashboard } from '@/components/LedgerDashboard';
export default function ExplorerPage() {
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Stellar Blockchain Explorer</h1>
<p className="text-gray-600 mb-8">
Powered by <a href="https://lumenquery.io" className="text-blue-600">LumenQuery</a> Horizon API
</p>
<div className="space-y-12">
<section>
<LedgerDashboard />
</section>
<section>
<AccountViewer />
</section>
<section>
<TransactionList />
</section>
</div>
</div>
);
}Handling Pagination
The Horizon API uses cursor-based pagination. Here's how to implement infinite scroll:
// lib/pagination.ts
export function extractCursor(link: string): string | null {
const url = new URL(link);
return url.searchParams.get('cursor');
}
export async function fetchPage(endpoint: string, cursor?: string) {
const url = cursor ? `${endpoint}&cursor=${cursor}` : endpoint;
return horizonRequest(url);
}// components/PaginatedTransactions.tsx
'use client';
import { useState, useEffect, useCallback } from 'react';
import { horizonRequest } from '@/lib/horizon';
import { extractCursor } from '@/lib/pagination';
export function PaginatedTransactions() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
try {
const endpoint = nextCursor
? `/transactions?limit=20&order=desc&cursor=${nextCursor}`
: '/transactions?limit=20&order=desc';
const data = await horizonRequest(endpoint);
setTransactions((prev) => [...prev, ...data._embedded.records]);
if (data._links.next) {
setNextCursor(extractCursor(data._links.next.href));
} else {
setNextCursor(null);
}
} finally {
setLoading(false);
}
}, [nextCursor, loading]);
useEffect(() => {
loadMore();
}, []);
return (
<div>
{/* Transaction list rendering */}
{transactions.map((tx) => (
<div key={tx.id}>{/* Transaction display */}</div>
))}
{nextCursor && (
<button
onClick={loadMore}
disabled={loading}
className="w-full py-2 mt-4 bg-gray-100 rounded hover:bg-gray-200"
>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}Best Practices
Error Handling
Always handle API errors gracefully:
async function safeHorizonRequest<T>(endpoint: string): Promise<T | null> {
try {
return await horizonRequest(endpoint);
} catch (error) {
if (error instanceof Error) {
console.error(`Horizon API Error: ${error.message}`);
}
return null;
}
}Rate Limiting
LumenQuery provides generous rate limits, but always implement retry logic:
async function fetchWithRetry(endpoint: string, retries = 3): Promise<any> {
for (let i = 0; i < retries; i++) {
try {
return await horizonRequest(endpoint);
} catch (error) {
if (i === retries - 1) throw error;
await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
}
}
}Caching
Cache frequently accessed data:
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL = 5000; // 5 seconds
async function cachedRequest(endpoint: string) {
const cached = cache.get(endpoint);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
const data = await horizonRequest(endpoint);
cache.set(endpoint, { data, timestamp: Date.now() });
return data;
}Next Steps
You now have a working Stellar blockchain explorer. Here are some ways to extend it:
*Ready to build your own Stellar explorer? Sign up for LumenQuery and get started with reliable Horizon API infrastructure.*