Developer Guide

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:

  • Fetch and display recent transactions
  • Query any Stellar account and show balances
  • Parse and display operation details
  • Show real-time ledger data
  • Handle pagination for large datasets
  • Prerequisites

    Before we start, you'll need:

  • Node.js 18+ installed
  • A LumenQuery API key (sign up free)
  • Basic JavaScript/TypeScript knowledge
  • A code editor
  • 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 install

    Set your LumenQuery API key:

    # .env.local
    LUMENQUERY_API_KEY=lq_your_api_key_here
    NEXT_PUBLIC_HORIZON_URL=https://api.lumenquery.io

    Creating 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:

  • Add asset tracking - Show all assets issued on Stellar
  • Implement search - Search by account, transaction hash, or ledger
  • Add analytics - Show network statistics and trends
  • Real-time streaming - Use Horizon's streaming API for live updates
  • Mobile responsiveness - Make the explorer work on all devices

  • *Ready to build your own Stellar explorer? Sign up for LumenQuery and get started with reliable Horizon API infrastructure.*