Documentation Next.js

はじめに

この記事では、Next.jsを使ってWeb3対応のdApp(分散型アプリケーション)を構築する方法を解説します。Web3とは、ブロックチェーン技術を基盤とした分散型のインターネットを指し、ユーザーが自身のデータやデジタル資産を直接管理できる特徴があります。

具体的には、以下の内容を学べます。

  • Wagmiライブラリを使ったウォレット接続の実装
  • MetaMaskとの連携方法
  • スマートコントラクトからのデータ読み取りと書き込み
  • RainbowKitを使った美しいUIの実装

前提知識

この記事を理解するために、以下の知識があると役立ちます。

用語説明
Web3ブロックチェーン技術を基盤とした分散型インターネット。中央管理者なしでユーザー同士が直接やり取りできる
dAppDecentralized Application(分散型アプリケーション)の略。ブロックチェーン上で動作するアプリケーション
ウォレット暗号資産を管理するためのソフトウェア。MetaMaskが最も普及している
スマートコントラクトブロックチェーン上で実行されるプログラム。条件が満たされると自動的に実行される
ABIApplication Binary Interfaceの略。スマートコントラクトとの通信方法を定義したJSON形式のファイル
Ethereumスマートコントラクトを実行できる代表的なブロックチェーンプラットフォーム

開発環境のセットアップ

必要なパッケージのインストール

Next.jsプロジェクトでWeb3を活用するために、必要なパッケージをインストールします。

# Wagmi v2とViemをインストール
npm install wagmi viem@2.x @tanstack/react-query

各パッケージの役割は以下のとおりです。

パッケージ役割
wagmiEthereumウォレットとの接続やブロックチェーン操作を簡単にするReact Hooks
viemEthereumとの低レベル通信を担当するライブラリ(ethers.jsの代替)
@tanstack/react-queryサーバー状態管理ライブラリ。Wagmi v2で必須

Wagmiの設定

プロジェクトのルートに設定ファイルを作成します。

// src/config/wagmi.ts
import { http, createConfig } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, metaMask } from "wagmi/connectors";

// Wagmiの設定を作成
export const config = createConfig({
  // 使用するブロックチェーンネットワークを指定
  chains: [mainnet, sepolia],
  // 利用可能なウォレットコネクタを設定
  connectors: [
    injected(), // ブラウザ拡張ウォレット全般
    metaMask(), // MetaMask専用
  ],
  // RPCプロバイダの設定
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http(), // テストネット
  },
});

プロバイダーの設定

アプリケーション全体でWagmiを使用できるようにプロバイダーを設定します。

// src/app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider } from "wagmi";
import { config } from "@/config/wagmi";
import { useState, type ReactNode } from "react";

// プロバイダーコンポーネント
export function Providers({ children }: { children: ReactNode }) {
  // QueryClientをステートで管理(SSR対応)
  const [queryClient] = useState(() => new QueryClient());

  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  );
}
// src/app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

ウォレット接続の実装

基本的なウォレット接続コンポーネント

useConnectフックを使用して、ユーザーがウォレットを接続できるボタンを実装します。

// src/components/ConnectWallet.tsx
"use client";

import { useConnect, useAccount, useDisconnect } from "wagmi";

export function ConnectWallet() {
  // ウォレット接続用のフック
  const { connect, connectors, isPending, error } = useConnect();
  // 現在の接続状態を取得
  const { address, isConnected } = useAccount();
  // 切断用のフック
  const { disconnect } = useDisconnect();

  // 既に接続済みの場合
  if (isConnected) {
    return (
      <div className="p-4 border rounded-lg">
        <p className="mb-2">
          接続中: {address?.slice(0, 6)}...{address?.slice(-4)}
        </p>
        <button
          onClick={() => disconnect()}
          className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
        >
          切断する
        </button>
      </div>
    );
  }

  // 未接続の場合、利用可能なコネクタを表示
  return (
    <div className="p-4 border rounded-lg">
      <h2 className="text-lg font-bold mb-4">ウォレットを接続</h2>
      <div className="space-y-2">
        {connectors.map((connector) => (
          <button
            key={connector.uid}
            onClick={() => connect({ connector })}
            disabled={isPending}
            className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
          >
            {isPending ? "接続中..." : `${connector.name}で接続`}
          </button>
        ))}
      </div>
      {error && <p className="mt-2 text-red-500">{error.message}</p>}
    </div>
  );
}

アカウント情報の表示

接続されたウォレットの詳細情報を表示するコンポーネントを作成します。

// src/components/AccountInfo.tsx
"use client";

import { useAccount, useBalance } from "wagmi";

export function AccountInfo() {
  // アカウント情報を取得
  const { address, chain, isConnected } = useAccount();

  // ETH残高を取得
  const { data: balance, isLoading } = useBalance({
    address: address,
  });

  if (!isConnected) {
    return <p>ウォレットが接続されていません</p>;
  }

  return (
    <div className="p-4 border rounded-lg space-y-2">
      <h2 className="text-lg font-bold">アカウント情報</h2>
      <p>
        <strong>アドレス:</strong> {address}
      </p>
      <p>
        <strong>ネットワーク:</strong> {chain?.name ?? "不明"}
      </p>
      <p>
        <strong>残高:</strong>{" "}
        {isLoading
          ? "読み込み中..."
          : `${balance?.formatted} ${balance?.symbol}`}
      </p>
    </div>
  );
}

スマートコントラクトとの連携

コントラクトからデータを読み取る

useReadContractフックを使用して、スマートコントラクトからデータを取得します。

// src/components/ReadContract.tsx
"use client";

import { useReadContract } from "wagmi";

// ERC-20トークンのABI(必要な関数のみ抜粋)
const erc20Abi = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ type: "uint256" }],
  },
  {
    name: "symbol",
    type: "function",
    stateMutability: "view",
    inputs: [],
    outputs: [{ type: "string" }],
  },
  {
    name: "decimals",
    type: "function",
    stateMutability: "view",
    inputs: [],
    outputs: [{ type: "uint8" }],
  },
] as const;

// コントラクトアドレス(例: USDC on Ethereum)
const contractAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

type ReadContractProps = {
  userAddress: `0x${string}`;
};

export function ReadContract({ userAddress }: ReadContractProps) {
  // トークンシンボルを取得
  const { data: symbol } = useReadContract({
    address: contractAddress,
    abi: erc20Abi,
    functionName: "symbol",
  });

  // トークン残高を取得
  const {
    data: balance,
    isLoading,
    isError,
    error,
  } = useReadContract({
    address: contractAddress,
    abi: erc20Abi,
    functionName: "balanceOf",
    args: [userAddress],
  });

  // 小数点以下の桁数を取得
  const { data: decimals } = useReadContract({
    address: contractAddress,
    abi: erc20Abi,
    functionName: "decimals",
  });

  if (isLoading) {
    return <div className="p-4">読み込み中...</div>;
  }

  if (isError) {
    return <div className="p-4 text-red-500">エラー: {error?.message}</div>;
  }

  // 残高を人間が読める形式に変換
  const formattedBalance =
    balance && decimals
      ? (Number(balance) / Math.pow(10, decimals)).toFixed(2)
      : "0";

  return (
    <div className="p-4 border rounded-lg">
      <h2 className="text-lg font-bold mb-2">トークン残高</h2>
      <p>
        {formattedBalance} {symbol}
      </p>
    </div>
  );
}

コントラクトにトランザクションを送信する

useWriteContractフックを使用して、スマートコントラクトにトランザクションを送信します。

// src/components/WriteContract.tsx
"use client";

import { useState } from "react";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";

// ERC-20トークンの送金用ABI
const erc20TransferAbi = [
  {
    name: "transfer",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ type: "bool" }],
  },
] as const;

type WriteContractProps = {
  contractAddress: `0x${string}`;
};

export function WriteContract({ contractAddress }: WriteContractProps) {
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");

  // トランザクション送信用のフック
  const { data: hash, isPending, writeContract, error } = useWriteContract();

  // トランザクションの完了を待機
  const { isLoading: isConfirming, isSuccess: isConfirmed } =
    useWaitForTransactionReceipt({
      hash,
    });

  // 送金を実行
  const handleTransfer = () => {
    if (!recipient || !amount) return;

    writeContract({
      address: contractAddress,
      abi: erc20TransferAbi,
      functionName: "transfer",
      args: [recipient as `0x${string}`, parseUnits(amount, 6)], // USDC: 6 decimals
    });
  };

  return (
    <div className="p-4 border rounded-lg space-y-4">
      <h2 className="text-lg font-bold">トークンを送金</h2>

      <div>
        <label className="block text-sm font-medium mb-1">送金先アドレス</label>
        <input
          type="text"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
          placeholder="0x..."
          className="w-full px-3 py-2 border rounded"
        />
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">金額</label>
        <input
          type="number"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
          placeholder="0.00"
          className="w-full px-3 py-2 border rounded"
        />
      </div>

      <button
        onClick={handleTransfer}
        disabled={isPending || isConfirming}
        className="w-full px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
      >
        {isPending
          ? "署名を待機中..."
          : isConfirming
            ? "トランザクション処理中..."
            : "送金する"}
      </button>

      {hash && (
        <p className="text-sm">
          トランザクションハッシュ: {hash.slice(0, 10)}...
        </p>
      )}

      {isConfirmed && (
        <p className="text-green-500">トランザクションが完了しました</p>
      )}

      {error && <p className="text-red-500">エラー: {error.message}</p>}
    </div>
  );
}

RainbowKitを使ったUI実装

RainbowKitを使用すると、美しいウォレット接続モーダルを簡単に実装できます。

RainbowKitのインストール

npm install @rainbow-me/rainbowkit

RainbowKitの設定

// src/config/rainbowkit.ts
import { getDefaultConfig } from "@rainbow-me/rainbowkit";
import { mainnet, sepolia } from "wagmi/chains";

// RainbowKitの設定
export const config = getDefaultConfig({
  appName: "My Web3 App",
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, // WalletConnectのプロジェクトID
  chains: [mainnet, sepolia],
  ssr: true, // Next.js App RouterではSSRを有効に
});

RainbowKitプロバイダーの設定

// src/app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider } from "wagmi";
import { RainbowKitProvider } from "@rainbow-me/rainbowkit";
import { config } from "@/config/rainbowkit";
import { useState, type ReactNode } from "react";
import "@rainbow-me/rainbowkit/styles.css";

export function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>{children}</RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

ConnectButtonの使用

// src/app/page.tsx
"use client";

import { ConnectButton } from "@rainbow-me/rainbowkit";

export default function Home() {
  return (
    <main className="min-h-screen p-8">
      <h1 className="text-3xl font-bold mb-8">My Web3 App</h1>

      {/* RainbowKitのConnectButtonを使用 */}
      <ConnectButton />

      {/* カスタマイズも可能 */}
      <ConnectButton.Custom>
        {({
          account,
          chain,
          openAccountModal,
          openChainModal,
          openConnectModal,
          mounted,
        }) => {
          const connected = mounted && account && chain;

          return (
            <div>
              {!connected ? (
                <button
                  onClick={openConnectModal}
                  className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
                >
                  ウォレットを接続
                </button>
              ) : (
                <div className="flex gap-2">
                  <button
                    onClick={openChainModal}
                    className="px-4 py-2 bg-gray-200 rounded"
                  >
                    {chain.name}
                  </button>
                  <button
                    onClick={openAccountModal}
                    className="px-4 py-2 bg-gray-200 rounded"
                  >
                    {account.displayName}
                  </button>
                </div>
              )}
            </div>
          );
        }}
      </ConnectButton.Custom>
    </main>
  );
}

エラーハンドリングのベストプラクティス

Web3アプリケーションでは、様々なエラーが発生する可能性があります。適切なエラーハンドリングを実装しましょう。

// src/hooks/useWeb3Error.ts
import { useCallback } from "react";

// Web3でよくあるエラーの種類
type Web3ErrorType =
  | "USER_REJECTED" // ユーザーがトランザクションを拒否
  | "INSUFFICIENT_FUNDS" // 残高不足
  | "NETWORK_ERROR" // ネットワークエラー
  | "CONTRACT_ERROR" // コントラクトエラー
  | "UNKNOWN"; // 不明なエラー

export function useWeb3Error() {
  // エラーメッセージを解析して適切なエラータイプを返す
  const parseError = useCallback((error: Error): Web3ErrorType => {
    const message = error.message.toLowerCase();

    if (message.includes("user rejected") || message.includes("user denied")) {
      return "USER_REJECTED";
    }
    if (
      message.includes("insufficient funds") ||
      message.includes("exceeds balance")
    ) {
      return "INSUFFICIENT_FUNDS";
    }
    if (
      message.includes("network") ||
      message.includes("timeout") ||
      message.includes("disconnected")
    ) {
      return "NETWORK_ERROR";
    }
    if (
      message.includes("execution reverted") ||
      message.includes("contract")
    ) {
      return "CONTRACT_ERROR";
    }
    return "UNKNOWN";
  }, []);

  // ユーザーフレンドリーなエラーメッセージを生成
  const getErrorMessage = useCallback((errorType: Web3ErrorType): string => {
    const messages: Record<Web3ErrorType, string> = {
      USER_REJECTED: "トランザクションがキャンセルされました",
      INSUFFICIENT_FUNDS: "残高が不足しています",
      NETWORK_ERROR:
        "ネットワークエラーが発生しました。接続を確認してください",
      CONTRACT_ERROR:
        "コントラクトの実行に失敗しました。入力値を確認してください",
      UNKNOWN: "予期しないエラーが発生しました",
    };
    return messages[errorType];
  }, []);

  return { parseError, getErrorMessage };
}

まとめ

この記事では、Next.jsとWeb3を組み合わせたdAppの構築方法を解説しました。

学んだこと

  • Wagmi v2を使用したウォレット接続の実装
  • useReadContractとuseWriteContractによるスマートコントラクトとの連携
  • RainbowKitを使った美しいUIの実装
  • 適切なエラーハンドリングの方法

次のステップ

  • テストネット(Sepolia)でのテスト実施
  • 独自のスマートコントラクトのデプロイ
  • ENS(Ethereum Name Service)との連携
  • NFTの表示・ミント機能の実装

Web3の世界は急速に発展しています。この記事で学んだ基礎を元に、さらに高度なdAppを開発してみてください。

参考文献

円