跳转到内容

Next.js 最佳实践

Next.js 最佳实践

本页内容正在整理中,欢迎贡献

概述

Next.js 是构建以太坊 DApp 前端的主流框架,提供 SSR/SSG、文件路由、API Routes 等功能。本文介绍如何在 Next.js App Router 中正确集成 wagmi/viem,处理客户端/服务端渲染差异,以及 DApp 特有的性能优化技巧。

主要内容

项目初始化

Terminal window
npx create-next-app@latest my-dapp \
--typescript \
--tailwind \
--app \
--src-dir
cd my-dapp
npm install wagmi viem @tanstack/react-query @rainbow-me/rainbowkit

目录结构推荐

src/
├── app/
│ ├── layout.tsx # 根 Layout,包含 Providers
│ ├── page.tsx # 首页
│ └── api/
│ └── nonce/route.ts # SIWE nonce API
├── components/
│ ├── wallet/ # 钱包相关组件
│ ├── contract/ # 合约交互组件
│ └── ui/ # 通用 UI 组件
├── hooks/
│ ├── useTokenBalance.ts # 自定义 wagmi hooks
│ └── useNFTs.ts
├── lib/
│ ├── wagmi.ts # wagmi config
│ ├── contracts.ts # 合约 ABI 和地址
│ └── utils.ts
└── types/
└── contracts.ts # 合约类型定义

处理 SSR/Hydration

wagmi 的 hooks 只能在客户端运行,需要正确处理:

lib/wagmi.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
export const wagmiConfig = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected(),
walletConnect({ projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID! }),
],
transports: {
[mainnet.id]: http(),
[sepolia.id]: http(process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL),
},
// 重要:禁用 SSR(避免 hydration 错误)
ssr: true,
});
app/providers.tsx
"use client"; // 必须标记为客户端组件
import { WagmiProvider, cookieToInitialState } from "wagmi";
import { wagmiConfig } from "@/lib/wagmi";
export function Providers({
children,
cookie,
}: {
children: React.ReactNode;
cookie?: string | null;
}) {
const initialState = cookieToInitialState(wagmiConfig, cookie);
return (
<WagmiProvider config={wagmiConfig} initialState={initialState}>
{/* ... */}
</WagmiProvider>
);
}

合约地址与 ABI 管理

lib/contracts.ts
import { Address } from "viem";
import { mainnet, sepolia } from "wagmi/chains";
export const CONTRACT_ADDRESSES: Record<number, { myToken: Address }> = {
[mainnet.id]: {
myToken: "0x主网地址" as Address,
},
[sepolia.id]: {
myToken: "0xSepolia地址" as Address,
},
};
// ABI 使用 as const 保留完整类型信息
export const MY_TOKEN_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
] as const;

自定义 Hooks 封装

hooks/useTokenBalance.ts
import { useAccount, useChainId, useReadContract } from "wagmi";
import { CONTRACT_ADDRESSES, MY_TOKEN_ABI } from "@/lib/contracts";
import { formatEther } from "viem";
export function useTokenBalance() {
const { address } = useAccount();
const chainId = useChainId();
const contractAddress = CONTRACT_ADDRESSES[chainId]?.myToken;
const { data, isLoading, refetch } = useReadContract({
address: contractAddress,
abi: MY_TOKEN_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address && !!contractAddress },
});
return {
balance: data ? formatEther(data) : "0",
isLoading,
refetch,
};
}

环境变量配置

.env.local
NEXT_PUBLIC_WC_PROJECT_ID=your_walletconnect_project_id
NEXT_PUBLIC_SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/KEY
NEXT_PUBLIC_MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/KEY
# 服务端私密变量(不加 NEXT_PUBLIC_ 前缀)
ALCHEMY_API_KEY=your_key

API Routes:后端 Web3 操作

app/api/mint/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
// 后台铸造(例如验证后免 Gas 为用户铸造)
export async function POST(req: NextRequest) {
const { recipientAddress } = await req.json();
const account = privateKeyToAccount(process.env.MINTER_PRIVATE_KEY as `0x${string}`);
const client = createWalletClient({
account,
chain: sepolia,
transport: http(process.env.SEPOLIA_RPC_URL),
});
const hash = await client.writeContract({
address: NFT_CONTRACT,
abi: NFT_ABI,
functionName: "mint",
args: [recipientAddress],
});
return NextResponse.json({ hash });
}

性能优化

  1. 合约 ABI Tree Shaking:只导入用到的 ABI 片段
  2. useReadContracts:批量读取多个合约数据,减少 RPC 调用
  3. 查询缓存:通过 staleTime 控制数据刷新频率
  4. 动态导入:对重型 Web3 组件使用 next/dynamic
// 动态导入避免 SSR 问题
const WalletModal = dynamic(() => import("@/components/WalletModal"), {
ssr: false,
});

深入阅读