01 / INITIALIZE
Start with the app.
Use Node.js 22.13 or later. The download includes a complete package manifest and a guide; the following commands show how to start from a clean Next.js application.
npx create-next-app@16.3.4 skooby-web3 --typescript --tailwind --eslint --app --src-dir --import-alias '@/*' --use-npm
cd skooby-web3
npm install @privy-io/react-auth@3.42.0 @privy-io/wagmi@4.0.17 wagmi@3.7.7 viem@2.56.0 @tanstack/react-query@5.102.8 @privy-io/node@0.34.0 pinata@2.5.6 server-only@0.0.1 @stripe/stripe-js@9.16.0
# Copy the starter src/ and .env.example into your application.
cp .env.example .env.local
npm run devCreate your own provider accounts and add configuration to .env.local. Public app IDs may reach the browser. App secrets, Pinata JWTs and database credentials must stay on the server.
02 / PROVIDERS
One connected application.
Privy wraps TanStack Query and the Privy-aware Wagmi provider. Base and Polygon are configured together. The layout remains a Server Component; only interactive components use client hooks.
src/components/Web3Provider.tsx
'use client';
import {useState,type ReactNode} from 'react';
import {PrivyProvider} from '@privy-io/react-auth';
// Privy's wrapper keeps embedded and external wallets synchronized with Wagmi.
import {createConfig,WagmiProvider} from '@privy-io/wagmi';
import {QueryClient,QueryClientProvider} from '@tanstack/react-query';
import {http} from 'wagmi';
import {base,polygon} from 'viem/chains';
import {chain} from '@/lib/chain';
export default function Web3Provider({children}:{children:ReactNode}){
// Each mounted app gets its own cache. Never share a server-global user cache.
const [queryClient]=useState(()=>new QueryClient({defaultOptions:{queries:{staleTime:15_000,retry:1}}}));
const [config]=useState(()=>createConfig({
chains:[base,polygon],ssr:true,
transports:{[base.id]:http(process.env.NEXT_PUBLIC_BASE_RPC_URL),[polygon.id]:http(process.env.NEXT_PUBLIC_POLYGON_RPC_URL)}
}));
const appId=process.env.NEXT_PUBLIC_PRIVY_APP_ID;
if(!appId)return <main className="mx-auto max-w-xl p-8"><h1>Web3 starter</h1><p>Set your Privy app ID to enable login. No wallet is connected yet.</p></main>;
return <PrivyProvider appId={appId} config={{
loginMethods:['email','google','wallet'],
appearance:{theme:'dark',accentColor:'#a879e0',showWalletLoginFirst:false,walletList:['coinbase_wallet','metamask','rainbow','wallet_connect']},
defaultChain:chain,supportedChains:[base,polygon],
// Current Privy v3 configuration: ethereum.createOnLogin, not the old top-level field.
embeddedWallets:{ethereum:{createOnLogin:'users-without-wallets'}}
}}><QueryClientProvider client={queryClient}><WagmiProvider config={config}>{children}</WagmiProvider></QueryClientProvider></PrivyProvider>;
}
src/app/layout.tsx
import type {Metadata} from 'next';
import Web3Provider from '@/components/Web3Provider';
import './globals.css';
export const metadata:Metadata={title:'Skooby Web3 Starter',description:'Email login, embedded wallets, server-verified token access and public IPFS uploads.'};
// The layout stays a Server Component. Only the provider and interactive leaves are clients.
export default function Layout({children}:{children:React.ReactNode}){return <html lang="en"><body><Web3Provider>{children}</Web3Provider></body></html>;}
Enable email and Google in the Privy dashboard, configure allowed domains, and set your public app ID. New email/social users get an embedded Ethereum wallet when they do not already have a wallet.
03 / AUTHENTICATION
Connect without a crypto crash course.
The Connect button opens Privy’s login modal. Users can choose email, Google, Coinbase Wallet, MetaMask, Rainbow or WalletConnect. The component handles setup, active-wallet selection and sign-out.
src/components/ConnectButton.tsx
'use client';
import {useState} from 'react';
import {usePrivy,useWallets} from '@privy-io/react-auth';
import {useSetActiveWallet} from '@privy-io/wagmi';
import {useAccount} from 'wagmi';
export default function ConnectButton(){
const {ready,authenticated,login,logout,connectWallet}=usePrivy();
const {wallets}=useWallets();const {setActiveWallet}=useSetActiveWallet();
const {address}=useAccount();const [error,setError]=useState('');
if(!ready)return <button disabled>Getting ready…</button>;
if(!authenticated)return <button onClick={()=>login()}>Connect with email, Google or a wallet</button>;
return <section className="space-y-3"><p>{address?`Connected: ${address}`:'Preparing your embedded wallet…'}</p>
{wallets.length>1&&<label>Active wallet<select value={address||''} onChange={async e=>{const selected=wallets.find(w=>w.address===e.target.value);if(selected){try{await setActiveWallet(selected);setError('');}catch{setError('Wallet switch canceled.');}}}}>{wallets.map(w=><option key={w.address} value={w.address}>{w.walletClientType}: {w.address}</option>)}</select></label>}
<div className="flex flex-wrap gap-3"><button onClick={()=>connectWallet()}>Add Coinbase or another wallet</button><button onClick={()=>void logout()}>Sign out</button></div><p role="status">{error}</p>
</section>;
}
Link and verify an external wallet to the authenticated identity before using server-protected content. A client-side connection alone does not prove ownership to your API.
04 / TOKEN ACCESS
Check the token. Verify the user.
Choose an ERC-721 NFT or ERC-20 token on the configured chain. The UI reads the wallet with Wagmi. The server independently verifies the Privy token, wallet ownership and on-chain balance before returning exclusive content.
src/lib/chain.ts
import {base,polygon} from 'viem/chains';
import {isAddress,type Address} from 'viem';
// One chain and gate policy are shared by the UI and the server.
// Changing a NEXT_PUBLIC value requires rebuilding the application.
export const chain=process.env.NEXT_PUBLIC_CHAIN==='polygon'?polygon:base;
export function gateConfig(){
const address=process.env.NEXT_PUBLIC_GATE_CONTRACT;
if(!address||!isAddress(address))throw Error('Set a valid gate contract before enabling this feature.');
const kind=process.env.NEXT_PUBLIC_GATE_STANDARD;
if(kind!=='erc721'&&kind!=='erc20')throw Error('Gate standard must be erc721 or erc20.');
const minimum=process.env.NEXT_PUBLIC_GATE_MINIMUM||'1';
if(!/^\d+(\.\d+)?$/.test(minimum)||Number(minimum)<=0)throw Error('Gate minimum must be positive.');
return {address:address as Address,kind,minimum};
}
src/components/TokenGate.tsx
'use client';
import {useEffect,useState} from 'react';
import {usePrivy} from '@privy-io/react-auth';
import {useAccount,useReadContract} from 'wagmi';
import {erc20Abi,erc721Abi,parseUnits} from 'viem';
import {chain,gateConfig} from '@/lib/chain';
export default function TokenGate(){
// Configuration errors are rendered separately, rather than a misleading zero balance.
let gate:ReturnType<typeof gateConfig>;try{gate=gateConfig();}catch{return <p>Configure a gate contract and standard in .env.local to try token access.</p>;}
return <ConfiguredGate gate={gate}/>;
}
function ConfiguredGate({gate}:{gate:ReturnType<typeof gateConfig>}){
const {address}=useAccount();const {authenticated,getAccessToken}=usePrivy();
const [result,setResult]=useState<{address:string;content:string}|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false);
const balance=useReadContract({chainId:chain.id,address:gate.address,abi:gate.kind==='erc721'?erc721Abi:erc20Abi,functionName:'balanceOf',args:address?[address]:undefined,query:{enabled:!!address&&authenticated,refetchInterval:15000}});
const decimals=useReadContract({chainId:chain.id,address:gate.address,abi:erc20Abi,functionName:'decimals',query:{enabled:gate.kind==='erc20'&&!!address&&authenticated}});
let eligible=false;let precisionError=false;
if(typeof balance.data==='bigint'){
if(gate.kind==='erc721')eligible=balance.data>0n;
else if(typeof decimals.data==='number'){precisionError=(gate.minimum.split('.')[1]?.length||0)>decimals.data;if(!precisionError)eligible=balance.data>=parseUnits(gate.minimum,decimals.data);}
}
// Clear private UI on logout, wallet change or loss of client eligibility.
useEffect(()=>{setResult(null);setError('');},[address,authenticated,eligible]);
async function unlock(){if(!address)return;const requestedAddress=address;setBusy(true);setError('');try{const token=await getAccessToken();const r=await fetch('/api/exclusive',{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify({address:requestedAddress}),cache:'no-store'});const data=await r.json();if(!r.ok)throw Error(data.error);setResult({address:requestedAddress,content:data.content});}catch(e){setError(e instanceof Error?e.message:'Access check failed.');}finally{setBusy(false)}}
if(!authenticated||!address)return <p>Connect to check token access.</p>;
if(balance.isError||decimals.isError||precisionError)return <p role="alert">The contract could not be checked. Verify the chain, contract standard and token minimum.</p>;
if(balance.isPending||(gate.kind==='erc20'&&decimals.isPending))return <p role="status">Reading the contract on {chain.name}…</p>;
return <section className="space-y-3"><h2>Token-gated content</h2><p>Requirement: {gate.kind==='erc721'?'at least one NFT':gate.minimum+' tokens'} on {chain.name}. Checking is read-only and does not require switching your wallet’s signing network.</p>{eligible?<button disabled={busy} onClick={unlock}>{busy?'Verifying ownership…':'Verify and open exclusive content'}</button>:<p>This address does not meet the access requirement.</p>}<p role="status">{error}</p>{eligible&&authenticated&&result?.address===address&&<article>{result.content}</article>}</section>;
}
src/lib/auth.ts
import 'server-only';
import {PrivyClient} from '@privy-io/node';
import {isAddress,type Address} from 'viem';
export class HttpError extends Error{constructor(message:string,public status:number){super(message);}}
export function sameOrigin(request:Request){
// Use a configured origin, not an untrusted forwarded host supplied by a client.
if(!process.env.APP_ORIGIN||request.headers.get('origin')!==process.env.APP_ORIGIN)throw new HttpError('Invalid origin.',403);
}
export async function authenticatedUser(request:Request){
const bearer=request.headers.get('authorization');
if(!bearer?.startsWith('Bearer ')||bearer.length>10000)throw new HttpError('Please sign in again.',401);
const appId=process.env.NEXT_PUBLIC_PRIVY_APP_ID,appSecret=process.env.PRIVY_APP_SECRET;
if(!appId||!appSecret)throw new HttpError('Authentication is not configured.',503);
const privy=new PrivyClient({appId,appSecret});
try{
const claims=await privy.utils().auth().verifyAccessToken(bearer.slice(7));
// Fetch authoritative linked accounts; never accept a client-supplied user object.
const user=await privy.users()._get(claims.user_id);
return {id:claims.user_id,user};
}catch{throw new HttpError('Please sign in again.',401);}
}
export async function ownedWallet(request:Request,input:unknown){
if(typeof input!=='string'||!isAddress(input))throw new HttpError('Invalid wallet address.',400);
const {user,id}=await authenticatedUser(request);
const linked=user.linked_accounts.some(a=>a.type==='wallet'&&a.chain_type==='ethereum'&&a.address.toLowerCase()===input.toLowerCase());
if(!linked)throw new HttpError('Link and verify this wallet in your Privy account first.',403);
return {id,address:input as Address};
}
export function failure(error:unknown){return Response.json({error:error instanceof HttpError?error.message:'The request could not be completed.'},{status:error instanceof HttpError?error.status:503,headers:{'Cache-Control':'no-store'}});}
src/lib/gate.ts
import 'server-only';
import {createPublicClient,http,erc20Abi,erc721Abi,parseUnits,type Address} from 'viem';
import {chain,gateConfig} from './chain';
export async function holdsAccessToken(wallet:Address){
const gate=gateConfig();const client=createPublicClient({chain,transport:http(process.env.RPC_URL,{timeout:10000,retryCount:1})});
if(await client.getChainId()!==chain.id)throw Error('The RPC serves a different chain.');
// Pin all reads to one block so the balance and decimals are consistent.
const blockNumber=await client.getBlockNumber();
if(gate.kind==='erc721'){
const count=await client.readContract({address:gate.address,abi:erc721Abi,functionName:'balanceOf',args:[wallet],blockNumber});
return {allowed:count>0n,blockNumber};
}
const [balance,decimals]=await Promise.all([
client.readContract({address:gate.address,abi:erc20Abi,functionName:'balanceOf',args:[wallet],blockNumber}),
client.readContract({address:gate.address,abi:erc20Abi,functionName:'decimals',blockNumber})
]);
// ERC-20 decimals vary: USDC uses 6. Never assume 18 or compare Number balances.
if((gate.minimum.split('.')[1]?.length||0)>decimals)throw Error('Minimum has more precision than this token.');
return {allowed:balance>=parseUnits(gate.minimum,decimals),blockNumber};
}
src/app/api/exclusive/route.ts
import {sameOrigin,ownedWallet,failure,HttpError} from '@/lib/auth';
import {holdsAccessToken} from '@/lib/gate';
export const runtime='nodejs';
export async function POST(request:Request){try{
sameOrigin(request);const raw=await request.text();if(raw.length>512)throw new HttpError('Request too large.',413);
const {address}=await ownedWallet(request,JSON.parse(raw).address);
const result=await holdsAccessToken(address);
if(!result.allowed)throw new HttpError('This wallet does not meet the token requirement.',403);
// Protected content lives on the server. It is never shipped in the client JS bundle.
// Repeat this authorization for EVERY protected read/download/action.
return Response.json({content:'Welcome to the private builder desk. Replace this sample with your server-held exclusive content.',blockNumber:result.blockNumber.toString()},{headers:{'Cache-Control':'private, no-store'}});
}catch(error){return failure(error);}}
ERC-20 math uses bigint and the token’s actual decimals. RPC errors do not become zero balances. Client visibility never grants API access, and content checks repeat for every protected request.
05 / DECENTRALIZED STORAGE
Publish only what should be public.
The complete example uploads public text or JSON to Pinata using a server-only JWT. FormData carries the file; the browser adds the multipart boundary. The server verifies the user, enforces size/type limits and reserves a durable daily upload allowance.
src/components/IpfsUpload.tsx
'use client';
import {useState,type FormEvent} from 'react';
import {usePrivy} from '@privy-io/react-auth';
export default function IpfsUpload(){
const {authenticated,getAccessToken}=usePrivy();const [busy,setBusy]=useState(false),[message,setMessage]=useState('');
async function upload(e:FormEvent<HTMLFormElement>){e.preventDefault();const form=e.currentTarget;setBusy(true);setMessage('');try{
const token=await getAccessToken();const data=new FormData(form);
// Do not set Content-Type: fetch adds the multipart boundary for FormData.
const r=await fetch('/api/upload',{method:'POST',headers:{Authorization:`Bearer ${token}`},body:data});const result=await r.json();if(!r.ok)throw Error(result.error);setMessage(`Uploaded: ${result.uri}`);form.reset();
}catch(e){setMessage(e instanceof Error?e.message:'Upload could not be completed.');}finally{setBusy(false)}}
return <form onSubmit={upload} className="space-y-4"><h2>Publish a file to IPFS</h2><p>Public text or JSON, up to 2 MiB. Never upload private keys, identity documents or personal records. Copies may remain accessible even after unpinning.</p><label>File<input type="file" name="file" accept="text/plain,application/json,.txt,.json" required/></label><label className="flex gap-2"><input className="w-auto" type="checkbox" name="publicConsent" value="yes" required/>I own this file and consent to making it publicly accessible.</label><button disabled={!authenticated||busy}>{!authenticated?'Sign in to upload':busy?'Publishing…':'Publish public file'}</button><p className="break-all" role="status">{message}</p></form>;
}
src/app/api/upload/route.ts
import {PinataSDK} from 'pinata';
import {authenticatedUser,sameOrigin,failure,HttpError} from '@/lib/auth';
import {reserveUpload} from '@/lib/upload-quota';
export const runtime='nodejs';
const MAX_BYTES=2*1024*1024;
export async function POST(request:Request){try{
sameOrigin(request);const user=await authenticatedUser(request);
if(!process.env.PINATA_JWT)throw new HttpError('Storage is not configured.',503);
// Enforce a bounded body even if a client omits Content-Length.
const reader=request.body?.getReader();if(!reader)throw new HttpError('Choose a file.',400);
const chunks:Uint8Array[]=[];let size=0;
while(true){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>MAX_BYTES+16384){await reader.cancel();throw new HttpError('Choose a file smaller than 2 MiB.',413);}chunks.push(value);}
const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}
const form=await new Response(bytes,{headers:{'Content-Type':request.headers.get('content-type')||''}}).formData();
if(form.get('publicConsent')!=='yes')throw new HttpError('Confirm the file may be public on IPFS.',400);
const file=form.get('file');
if(!(file instanceof File)||file.size===0||file.size>MAX_BYTES)throw new HttpError('Choose a non-empty file up to 2 MiB.',400);
// Narrow example: UTF-8 plain text or valid JSON only, no active HTML or SVG.
if(!['text/plain','application/json'].includes(file.type))throw new HttpError('Choose a text or JSON file.',415);
const content=new TextDecoder('utf-8',{fatal:true}).decode(await file.arrayBuffer());
if(content.includes('\u0000'))throw new HttpError('Binary content is not supported.',415);
if(file.type==='application/json'){try{JSON.parse(content);}catch{throw new HttpError('Invalid JSON file.',400);}}
await reserveUpload(user.id);
// Only the server sees the scoped Pinata JWT. Do not expose it via NEXT_PUBLIC_.
const pinata=new PinataSDK({pinataJwt:process.env.PINATA_JWT,pinataGateway:process.env.PINATA_GATEWAY});
const safeName=`public-${crypto.randomUUID()}.${file.type==='application/json'?'json':'txt'}`;
const formattedFile=new File([content],safeName,{type:file.type});
const upload=await pinata.upload.public.file(formattedFile);
return Response.json({cid:upload.cid,uri:`ipfs://${upload.cid}`,bytes:upload.size},{headers:{'Cache-Control':'no-store'}});
}catch(error){return failure(error);}}
src/lib/upload-quota.ts
import 'server-only';
import {HttpError} from './auth';
export async function reserveUpload(userId:string){
const url=process.env.UPSTASH_REDIS_REST_URL,token=process.env.UPSTASH_REDIS_REST_TOKEN;
// A durable, atomic limit works across server instances. No in-memory production quota.
if(!url||!token)throw new HttpError('Uploads are not configured.',503);
const key=`uploads:${userId}:${Math.floor(Date.now()/86400000)}`;
const script="local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]); end; return n";
const r=await fetch(url,{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify(['EVAL',script,'1',key,'172800']),signal:AbortSignal.timeout(5000),cache:'no-store'});
if(!r.ok)throw new HttpError('Upload quota unavailable.',503);
const data=await r.json() as {result?:number;error?:string};
if(data.error||typeof data.result!=='number')throw new HttpError('Upload quota unavailable.',503);
if(data.result>5)throw new HttpError('Five daily upload attempts used. Try again tomorrow (UTC).',429);
}
IPFS identifiers are public. Removing your pin cannot guarantee that other copies disappear. Keep private documents and wallet secrets out of this upload flow.
What this starter changes.
It adds blockchain-based ownership checks and IPFS content identifiers to a familiar web app. Hosting, identity, RPC and pinning services remain dependencies. This toolkit is separate from Skooby’s current account login and research memberships.
Primary documentation: Privy + Wagmi · Viem contract reads · Pinata file uploads.