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
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 (
<Providers>{children}</Providers> ); }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' &&
Connecting...
} {!isConnected ? ( <button onClick={connect} className="px-4 py-2 bg-blue-600 text-white rounded" > Connect Wallet ) : (Connected: {address?.slice(0, 6)}...{address?.slice(-4)}
<button onClick={disconnect} className="ml-2 px-4 py-2 bg-red-600 text-white rounded" > Disconnect