はじめに
この記事では、Next.jsを使ってWeb3対応のdApp(分散型アプリケーション)を構築する方法を解説します。Web3とは、ブロックチェーン技術を基盤とした分散型のインターネットを指し、ユーザーが自身のデータやデジタル資産を直接管理できる特徴があります。
具体的には、以下の内容を学べます。
- Wagmiライブラリを使ったウォレット接続の実装
- MetaMaskとの連携方法
- スマートコントラクトからのデータ読み取りと書き込み
- RainbowKitを使った美しいUIの実装
前提知識
この記事を理解するために、以下の知識があると役立ちます。
| 用語 | 説明 |
|---|---|
| Web3 | ブロックチェーン技術を基盤とした分散型インターネット。中央管理者なしでユーザー同士が直接やり取りできる |
| dApp | Decentralized Application(分散型アプリケーション)の略。ブロックチェーン上で動作するアプリケーション |
| ウォレット | 暗号資産を管理するためのソフトウェア。MetaMaskが最も普及している |
| スマートコントラクト | ブロックチェーン上で実行されるプログラム。条件が満たされると自動的に実行される |
| ABI | Application Binary Interfaceの略。スマートコントラクトとの通信方法を定義したJSON形式のファイル |
| Ethereum | スマートコントラクトを実行できる代表的なブロックチェーンプラットフォーム |
開発環境のセットアップ
必要なパッケージのインストール
Next.jsプロジェクトでWeb3を活用するために、必要なパッケージをインストールします。
# Wagmi v2とViemをインストール
npm install wagmi viem@2.x @tanstack/react-query
各パッケージの役割は以下のとおりです。
| パッケージ | 役割 |
|---|---|
| wagmi | Ethereumウォレットとの接続やブロックチェーン操作を簡単にするReact Hooks |
| viem | Ethereumとの低レベル通信を担当するライブラリ(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を開発してみてください。
参考文献
- Wagmi公式ドキュメント - Wagmiの包括的なガイドとAPIリファレンス
- RainbowKit公式ドキュメント - RainbowKitの導入ガイドとカスタマイズ方法
- Viem公式ドキュメント - Viemの詳細な使用方法
- Ethereum Developer Documentation - Ethereum開発の公式リソース
- MetaMask Developer Documentation - MetaMaskとの連携に関する公式ドキュメント