TypeScript for Web3: Type-Safe State in Next.js
Introduction to Type-Safe Web3 Development
In 2026, Web3 applications demand rock-solid type safety to handle complex blockchain interactions without runtime errors. TypeScript excels here, bridging Next.js frontends with blockchain protocols like Ethereum. This guide delivers an end-to-end approach to state management that's fully typed, scalable, and production-ready.
Traditional state tools like Redux or Zustand work great for web apps, but Web3 adds layers: wallet connections, transaction states, chain data, and signer objects. Loose typing leads to bugs—like assuming a string address is valid or mishandling undefined providers.
We'll build a type-safe Web3 state manager using React Context, custom hooks, and ethers.js v6 (the 2026 standard). Expect actionable code you can copy-paste into your Next.js project.
Why TypeScript Shines in Web3 State Management
Web3 state is unpredictable:
- Wallets may or may not be connected.
- Networks switch dynamically.
- Transactions pend, succeed, or fail asynchronously.
TypeScript enforces:
- Union types for states (e.g.,
Connected | Disconnected | Loading). - Generics for chain-specific data.
- Narrowing for safe provider/signer access.
In Next.js 15+, TypeScript integrates natively with App Router, server components, and React 19's compiler optimizations.
Setting Up Your Next.js Web3 Project
Start with a fresh Next.js app:
npx create-next-app@latest my-web3-app --typescript --tailwind --eslint --app
cd my-web3-app
npm install ethers@^6.13.0 wagmi@^2.12.0 viem@^2.21.0 @tanstack/react-query@^5.59.0
npm install -D @types/node
wagmi and viem provide typed blockchain abstractions. They're the go-to in 2026 for EVM chains.
Project Structure
my-web3-app/
├── app/
│ ├── providers/
│ │ └── Web3Provider.tsx
│ └── layout.tsx
├── contexts/
│ └── Web3Context.tsx
├── hooks/
│ └── useWeb3Actions.ts
├── types/
│ └── web3.ts
└── components/
└── WalletConnect.tsx
Defining Type-Safe Web3 Interfaces
Create types/web3.ts for ironclad types:
// types/web3.ts
export type ChainId = 1 | 5 | 137 | 42161; // Mainnets + testnets
export interface Web3State {
isConnected: boolean;
address: `0x${string}` | null;
chainId: ChainId | null;
provider: any | null;
signer: any | null;
status: 'idle' | 'connecting' | 'connected' | 'error';
error: string | null;
}
export interface Web3Actions {
connect: () => Promise<void>;
disconnect: () => Promise<void>;
switchChain: (chainId: ChainId) => Promise<void>;
}
export type Web3ContextValue = Web3State & Web3Actions;
These types use template literals for hex addresses and discriminated unions for status—preventing invalid states at compile time.
Building the Core Web3 Context
In contexts/Web3Context.tsx, combine Context API with reducers for global state:
import { createContext, useContext, useReducer, ReactNode } from 'react';
import { Web3State, Web3Actions, ChainId } from '@/types/web3';
import { ethers } from 'ethers';
const Web3Context = createContext<Web3ContextValue | undefined>(undefined);
// Initial state
let initialState: Web3State = {
isConnected: false,
address: null,
chainId: null,
provider: null,
signer: null,
status: 'idle',
error: null,
};
// Reducer for type-safe updates
type Web3Action =
| { type: 'CONNECTING' }
| { type: 'CONNECTED'; payload: Web3State }
| { type: 'ERROR'; payload: string }
| { type: 'DISCONNECT' };
function web3Reducer(state: Web3State, action: Web3Action): Web3State {
switch (action.type) {
case 'CONNECTING':
return { ...state, status: 'connecting' };
case 'CONNECTED':
return { ...state, ...action.payload, status: 'connected' };
case 'ERROR':
return { ...state, status: 'error', error: action.payload };
case 'DISCONNECT':
return initialState;
default:
return state;
}
}
export const Web3Provider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(web3Reducer, initialState);
const connect = async () => {
if (typeof window === 'undefined') return;
try {
dispatch({ type: 'CONNECTING' });
const { ethereum } = window as any;
if (!ethereum) throw new Error('No wallet found');
const provider = new ethers.BrowserProvider(ethereum);
await provider.send('eth_requestAccounts', []);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const network = await provider.getNetwork();
dispatch({
type: 'CONNECTED',
payload: {
isConnected: true,
address: address as `0x${string}`,
chainId: Number(network.chainId) as ChainId,
provider,
signer,
},
});
} catch (error) {
dispatch({ type: 'ERROR', payload: (error as Error).message });
}
};
const disconnect = () => {
dispatch({ type: 'DISCONNECT' });
};
const switchChain = async (chainId: ChainId) => {
const { ethereum } = window as any;
try {
await ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: `0x${chainId.toString(16)}` }],
});
} catch (error) {
// Handle chain not added
}
};
return (
<Web3Context.Provider value={{ ...state, connect, disconnect, switchChain }}>
{children}
</Web3Context.Provider>
);
};
export const useWeb3 = () => {
const context = useContext(Web3Context);
if (!context) throw new Error('useWeb3 must be used within Web3Provider');
return context;
};
This reducer ensures immutable, predictable updates. TypeScript narrows types based on status—e.g., address is only non-null when isConnected.
Integrating with Next.js Providers
Wrap your app in app/providers/Web3Provider.tsx:
// app/providers/Web3Provider.tsx
import { Web3Provider } from '@/contexts/Web3Context';
export default function Providers({ children }: { children: React.ReactNode }) {
return <Web3Provider>{children}</Web3Provider>;
}
Update app/layout.tsx:
import Providers from './providers/Web3Provider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Practical Components: Wallet Connection UI
Build components/WalletConnect.tsx:
import { useWeb3 } from '@/contexts/Web3Context';
export default function WalletConnect() {
const { isConnected, address, status, connect, disconnect } = useWeb3();
return (
<div className="p-4 bg-gray-800 rounded-lg">
{status === 'connecting' && <p>Connecting...</p>}
{!isConnected ? (
<button
onClick={connect}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Connect Wallet
</button>
) : (
<div>
<p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
<button
onClick={disconnect}
className="ml-2 px-4 py-2 bg-red-600 text-white rounded"
>
Disconnect
</button>
</div>
)}
</div>
);
}
TypeScript ensures address access is safe—it's guarded by isConnected.
Advanced: Transaction State Management
Extend for transactions. Add to Web3State:
interface PendingTx {
hash: `0x${string}`;
status: 'pending' | 'success' | 'failed';
}
pendingTxs: PendingTx[];
addTransaction: (hash: `0x${string}`) => void;
Implement in reducer:
case 'ADD_TX':
return {
...state,
pendingTxs: [...state.pendingTxs, { hash: action.payload, status: 'pending' }],
};
Hook it into a sendTransaction action using your signer.
Bridging to Blockchain Protocols
Query on-chain data with type-safe ABIs. Define interfaces for your smart contracts:
// types/Contract.ts
interface TokenContract {
balanceOf: (address: `0x${string}`) => Promise<bigint>;
}
export const useTokenBalance = (contractAddress: `0x${string}`, tokenId: TokenContract) => {
const { address } = useWeb3();
// Fetch with viem or ethers, fully typed
};
For ICRC-1 or Azle canisters (Internet Computer), adapt types similarly.
Zustand Alternative for Simplicity
Prefer hooks over Context? Use Zustand with TypeScript:
// stores/useWeb3Store.ts
import { create } from 'zustand';
export const useWeb3Store = create<Web3ContextValue>((set, get) => ({
// ...state
connect: async () => {
set({ status: 'connecting' });
// impl
},
}));
Ultra-lightweight, devtools-ready, and persists state.
Performance Optimizations in 2026
- React 19: Use
useOptimisticfor instant tx feedback. - Next.js 15: Server-side wallet checks via cookies (never expose privkeys).
- TanStack Query: Cache blockchain data with invalidation on chain changes.
import { useQuery } from '@tanstack/react-query';
const { data: balance } = useQuery({
queryKey: ['balance', address],
queryFn: async () => {
if (!signer) return 0n;
// fetch
},
enabled: !!address,
});
Common Pitfalls and Fixes
| Pitfall | Fix |
|---|---|
| Stale closures in async | Use useCallback with getState() in Zustand |
| SSR hydration mismatch | Check typeof window in hooks |
| Chain ID mismatches | Validate with switchChain |
| BigInt serialization | Use toString() for JSON |
Real-World Example: NFT Marketplace State
Manage bids, listings, and user portfolio:
interface NFTState {
listings: Array<{ tokenId: bigint; price: bigint }>;
myBids: Map<bigint, bigint>;
}
// Full integration with above Web3Context
Fetch via provider, update via signer—all typed.
Testing Your Type-Safe Setup
// __tests__/Web3Context.test.tsx
import { renderHook, act } from '@testing-library/react';
const { result } = renderHook(() => useWeb3());
await act(async () => result.current.connect());
Use msw for EIP-1193 mocks.
Scaling to Multi-Chain
Support L2s like Base, Optimism:
const CHAINS = {
1: { name: 'Ethereum', rpc: 'https://mainnet.infura.io/v3/YOUR_KEY' },
8453: { name: 'Base', rpc: 'https://mainnet.base.org' },
} as Record<ChainId, { name: string; rpc: string }>;
Dynamic providers per chain.
Future-Proofing for 2026 Trends
- Account Abstraction: Typed
smartAccountstates with ERC-4337. - Cross-Chain: LayerZero or Axelar integrations with union types.
- AI Agents: Type-safe oracle feeds for on-chain AI data.
Your app will handle Web3's chaos gracefully.
Conclusion
This end-to-end type-safe state management turns Web3 dev from nightmare to joy. Deploy today—your users get buttery-smooth, error-free blockchain UIs. Fork the code, tweak for your protocol, and ship.
Next steps: Add wagmi for deeper integrations. Happy coding!