
Web3 social networks are replacing centralized platforms with user-owned identity, token-incentivized content, and censorship-resistant communication. But most guides stop at the concept level. This tutorial takes you from architecture design to production code.
By the end, you'll have a working web3 social media app with wallet-based auth, on-chain profiles, real-time chat, video calls, voice rooms, and NFT-gated communities — all built on Tencent RTC (TRTC) infrastructure for the communication layer.
Why Build a Web3 Social App
Traditional social media has three fundamental problems:
- Platform lock-in — Users don't own their social graph. Leave Twitter, lose your followers.
- Rent-seeking — Creators generate value; platforms extract it via ad revenue.
- Censorship risk — A single entity decides what speech is allowed.
Web3 social networks solve these by putting identity on-chain, storing content on decentralized storage, and using token economics to align platform and user incentives.
The market validates this shift. Lens Protocol has over 100K profiles. Farcaster grew 10x in early 2024. Nostr attracted mainstream attention. The infrastructure is ready — what's missing is polished user experiences that match Web2 quality, especially for real-time communication.
That's where web3 social media app development gets interesting: combining blockchain's ownership model with production-grade chat, video, and voice capabilities.
Web2 vs Web3 Social: The Ownership Shift
| Aspect | Web2 Social (Twitter, Discord) | Web3 Social Networks |
|---|---|---|
| Identity | Email/password, platform-owned | Wallet address + ENS/DID, user-owned |
| Social Graph | Locked in platform database | Portable across apps (Lens, Farcaster) |
| Content | Platform can delete/censor | Stored on IPFS/Arweave, immutable |
| Monetization | Platform takes 50-90% | Creator gets 80-95% via smart contracts |
| Community Access | Platform controls membership | NFT/token-gated, permissionless |
| Communication | Centralized servers | Hybrid: decentralized identity + optimized real-time delivery |
| Governance | Company board decides | DAO token holders vote |
Why Fully Decentralized Communication Fails in Practice
Here's a reality check that most web3 social media guides skip: fully decentralized video calls and messaging deliver terrible user experience. Peer-to-peer WebRTC without relay infrastructure means:
- 2-5 second connection times (vs 200ms with optimized servers)
- No guaranteed delivery for messages
- No group calls beyond 4-5 participants
- No message history after device changes
The practical architecture for web3 social apps is decentralized identity and data ownership combined with optimized real-time communication infrastructure. Users authenticate with wallets, own their data on-chain, but messages and video streams route through low-latency servers for quality.
This is where TRTC fits: it provides the communication layer while your blockchain layer handles identity and ownership.
Architecture Overview
A production web3 social app has four layers. Each can be built independently and composed together:
┌─────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ React/Next.js Frontend + Wallet UI + Social Feed │
├─────────────────────────────────────────────────────┤
│ COMMUNICATION LAYER │
│ TRTC Chat SDK │ TRTC Video SDK │ Voice Rooms │
├─────────────────────────────────────────────────────┤
│ IDENTITY LAYER │
│ Wallet Auth │ ENS/DID │ On-Chain Profiles │
├─────────────────────────────────────────────────────┤
│ STORAGE & BLOCKCHAIN LAYER │
│ IPFS/Arweave │ Smart Contracts │ Token Gates │
└─────────────────────────────────────────────────────┘Layer 1: Identity & Authentication
- Wallet connection (MetaMask, WalletConnect, Coinbase Wallet)
- ENS / Lens Handle resolution for human-readable names
- Session management with signed messages (SIWE — Sign-In with Ethereum)
- On-chain profile metadata (avatar NFT, bio, social links)
Layer 2: Communication (Real-Time)
This is where most web3 social networks fail. P2P protocols introduce latency and reliability issues. The practical solution: use battle-tested communication infrastructure with web3 auth on top.
- Chat: 1:1 messaging, group chats, community channels (TRTC Chat SDK)
- Video/Audio Calls: WebRTC-based, sub-300ms latency (TRTC Video Call)
- Voice Rooms: Clubhouse-style spaces for community engagement
Layer 3: Storage & Content
- IPFS / Arweave for media and post content
- On-chain metadata (post hashes, ownership records)
- Ceramic / ComposeDB for mutable user data
- Supabase for off-chain indexing and fast queries
Layer 4: Blockchain & Smart Contracts
- User profile NFTs (portable identity)
- Token-gated access control
- DAO governance contracts
- Reward distribution via ERC-20 tokens
Technology Stack
| Component | Technology | Purpose |
|---|---|---|
| Frontend | Next.js 14 + TypeScript | App shell, SSR for SEO |
| Wallet | wagmi + viem + RainbowKit | Multi-wallet support |
| Chat | TRTC Chat SDK | Real-time messaging |
| Video/Voice | TRTC Video SDK | Calls and voice rooms |
| Storage | IPFS (Pinata) + Arweave | Content and media |
| Blockchain | Polygon (low gas) | NFTs, tokens, governance |
| Smart Contracts | Solidity + Hardhat | Token gates, DAOs |
| Backend | Node.js + Next.js API Routes | Auth relay, TRTC token generation |
| Database | Supabase | Off-chain indexing, search |
Prerequisites and Project Setup
Before starting, set up your development environment:
# Node.js 18+
node --version
# Create project
npx create-next-app@latest web3-social --typescript --tailwind --app
cd web3-social
# Install core dependencies
npm install ethers@6 @tencentcloud/chat trtc-sdk-v5
npm install wagmi viem @rainbow-me/rainbowkit
npm install @tanstack/react-query zustand siwe
npm install @pinata/sdkStep 1: Wallet Authentication (SIWE)
Every web3 social app starts with wallet-based identity. We use Sign-In with Ethereum (SIWE) to create sessions without passwords.
Configure RainbowKit + wagmi
// src/providers/Web3Provider.tsx
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, polygon } from 'wagmi/chains';
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const config = getDefaultConfig({
appName: 'Web3 Social',
projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
chains: [mainnet, polygon],
});
const queryClient = new QueryClient();
export function Web3Provider({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}Implement SIWE Authentication
// src/hooks/useAuth.ts
import { useAccount, useSignMessage } from 'wagmi';
import { SiweMessage } from 'siwe';
export function useAuth() {
const { address, isConnected } = useAccount();
const { signMessageAsync } = useSignMessage();
const signIn = async () => {
if (!address) throw new Error('Wallet not connected');
// 1. Get nonce from backend
const nonceRes = await fetch('/api/auth/nonce', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
});
const { nonce } = await nonceRes.json();
// 2. Create SIWE message
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in to Web3 Social',
uri: window.location.origin,
version: '1',
chainId: 137, // Polygon
nonce,
});
// 3. Sign the message
const signature = await signMessageAsync({
message: message.prepareMessage(),
});
// 4. Verify on backend, get session + TRTC credentials
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature }),
});
return res.json(); // { token, trtcUserSig, userId, sdkAppId }
};
return { signIn, address, isConnected };
}Backend Verification + TRTC Token Generation
// src/app/api/auth/verify/route.ts
import { SiweMessage } from 'siwe';
import TLSSigAPIv2 from 'tls-sig-api-v2';
import { NextResponse } from 'next/server';
const TRTC_SDK_APP_ID = Number(process.env.TRTC_SDK_APP_ID);
const TRTC_SECRET_KEY = process.env.TRTC_SECRET_KEY!;
export async function POST(req: Request) {
const { message, signature } = await req.json();
// Verify SIWE signature
const siweMessage = new SiweMessage(message);
const { success } = await siweMessage.verify({ signature });
if (!success) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const walletAddress = siweMessage.address.toLowerCase();
// Generate TRTC UserSig for this wallet address
const api = new TLSSigAPIv2.Api(TRTC_SDK_APP_ID, TRTC_SECRET_KEY);
const userSig = api.genUserSig(walletAddress, 86400 * 7); // 7-day expiry
return NextResponse.json({
token: generateJWT(walletAddress),
trtcUserSig: userSig,
userId: walletAddress,
sdkAppId: TRTC_SDK_APP_ID,
});
}The wallet address becomes the user's unique identifier across both blockchain and communication layers. No email, no password, no data you don't control.
Step 2: On-Chain Profile with NFTs
User profiles in web3 social media should be portable. We mint a Profile NFT that stores metadata on IPFS and can be read by any dApp.
Profile Smart Contract
// contracts/SocialProfile.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
contract SocialProfile is ERC721, ERC721URIStorage {
uint256 private _tokenIdCounter;
mapping(address => uint256) public profileOf;
event ProfileCreated(address indexed owner, uint256 tokenId, string metadataURI);
event ProfileUpdated(address indexed owner, uint256 tokenId, string newURI);
constructor() ERC721("Web3Social Profile", "W3SP") {}
function createProfile(string memory metadataURI) external {
require(profileOf[msg.sender] == 0, "Profile exists");
_tokenIdCounter++;
uint256 tokenId = _tokenIdCounter;
_safeMint(msg.sender, tokenId);
_setTokenURI(tokenId, metadataURI);
profileOf[msg.sender] = tokenId;
emit ProfileCreated(msg.sender, tokenId, metadataURI);
}
function updateProfile(string memory newURI) external {
uint256 tokenId = profileOf[msg.sender];
require(tokenId != 0, "No profile");
_setTokenURI(tokenId, newURI);
emit ProfileUpdated(msg.sender, tokenId, newURI);
}
// Required overrides
function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC721URIStorage) returns (bool) {
return super.supportsInterface(interfaceId);
}
}Frontend Profile Creation
// src/hooks/useProfile.ts
import { useWriteContract } from 'wagmi';
export function useProfile() {
const { writeContractAsync } = useWriteContract();
const createProfile = async (profile: {
displayName: string;
bio: string;
avatar: File;
}) => {
// Upload avatar to IPFS
const avatarCID = await uploadToIPFS(profile.avatar);
// Create metadata JSON
const metadata = {
name: profile.displayName,
description: profile.bio,
image: `ipfs://${avatarCID}`,
attributes: [
{ trait_type: 'Platform', value: 'Web3Social' },
{ trait_type: 'Created', value: Date.now() },
],
};
const metadataCID = await uploadToIPFS(
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
);
// Mint profile NFT
await writeContractAsync({
address: PROFILE_CONTRACT_ADDRESS,
abi: SocialProfileABI,
functionName: 'createProfile',
args: [`ipfs://${metadataCID}`],
});
};
return { createProfile };
}
async function uploadToIPFS(file: File | Blob): Promise<string> {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/ipfs/upload', {
method: 'POST',
body: formData,
});
const { cid } = await res.json();
return cid;
}Users own this profile. If your app disappears tomorrow, their identity persists on-chain — accessible from any compatible client.
Step 3: Real-Time Chat with TRTC Chat SDK
This is where your web3 social app becomes actually social. The TRTC Chat SDK provides:
- 1:1 and group messaging with < 100ms delivery
- Message history and offline sync
- Typing indicators, read receipts, reactions
- File, image, and custom message types
- End-to-end encryption option
- Community groups with topic channels (up to 100,000 members)
Initialize Chat SDK
// src/lib/chat.ts
import TencentCloudChat from '@tencentcloud/chat';
const SDKAppID = Number(process.env.NEXT_PUBLIC_TRTC_SDK_APP_ID);
// Create Chat SDK instance
const chat = TencentCloudChat.create({
SDKAppID,
});
// Set log level (production: 0, development: 1)
chat.setLogLevel(process.env.NODE_ENV === 'development' ? 1 : 0);
export default chat;Login with Wallet Address as UserID
// src/hooks/useChat.ts
import { useEffect, useState, useCallback } from 'react';
import chat from '@/lib/chat';
import TencentCloudChat from '@tencentcloud/chat';
export function useChat(walletAddress: string | undefined) {
const [isReady, setIsReady] = useState(false);
const [messages, setMessages] = useState<any[]>([]);
const [conversations, setConversations] = useState<any[]>([]);
useEffect(() => {
if (!walletAddress) return;
const initChat = async () => {
// Generate userSig from backend (wallet address as userID)
const { userSig } = await fetch('/api/chat/usersig', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userID: walletAddress }),
}).then(r => r.json());
// Login to TRTC Chat
await chat.login({
userID: walletAddress,
userSig,
});
setIsReady(true);
};
// Listen for new messages
const onMessageReceived = (event: any) => {
setMessages(prev => [...prev, ...event.data]);
};
// Listen for conversation updates
const onConversationListUpdated = (event: any) => {
setConversations(event.data);
};
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
chat.on(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, onConversationListUpdated);
initChat();
return () => {
chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
chat.off(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, onConversationListUpdated);
chat.logout();
};
}, [walletAddress]);
const sendMessage = useCallback(async (to: string, text: string) => {
const message = chat.createTextMessage({
to,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { text },
});
const result = await chat.sendMessage(message);
setMessages(prev => [...prev, result.data.message]);
return result.data.message;
}, []);
const sendGroupMessage = useCallback(async (groupId: string, text: string) => {
const message = chat.createTextMessage({
to: groupId,
conversationType: TencentCloudChat.TYPES.CONV_GROUP,
payload: { text },
});
const result = await chat.sendMessage(message);
return result.data.message;
}, []);
// Send custom message (NFT shares, token transfer notifications, etc.)
const sendCustomMessage = useCallback(async (to: string, data: object) => {
const message = chat.createCustomMessage({
to,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: {
data: JSON.stringify(data),
description: 'web3-interaction',
extension: '',
},
});
await chat.sendMessage(message);
}, []);
const getMessageHistory = useCallback(async (conversationID: string, count = 20) => {
const result = await chat.getMessageList({ conversationID, count });
setMessages(result.data.messageList);
return result.data.messageList;
}, []);
return {
isReady,
messages,
conversations,
sendMessage,
sendGroupMessage,
sendCustomMessage,
getMessageHistory,
};
}Chat UI Component
// src/components/ChatWindow.tsx
'use client';
import { useState } from 'react';
import { useChat } from '@/hooks/useChat';
import { useAccount } from 'wagmi';
function formatAddress(addr: string) {
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}
export function ChatWindow({ peerAddress }: { peerAddress: string }) {
const { address } = useAccount();
const { messages, sendMessage, isReady } = useChat(address);
const [input, setInput] = useState('');
const handleSend = async () => {
if (!input.trim()) return;
await sendMessage(peerAddress, input);
setInput('');
};
if (!isReady) return <div className="p-4">Connecting to chat...</div>;
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.from === address ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-xs px-4 py-2 rounded-2xl ${
msg.from === address ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-900'
}`}>
<p className="text-xs opacity-70">{formatAddress(msg.from)}</p>
<p>{msg.payload.text}</p>
</div>
</div>
))}
</div>
<div className="border-t p-4 flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="Message..."
className="flex-1 px-4 py-2 rounded-full border focus:outline-none focus:ring-2"
/>
<button onClick={handleSend} className="px-6 py-2 bg-blue-600 text-white rounded-full">
Send
</button>
</div>
</div>
);
}DAO Group Chat with Token-Gating
Web3 social networks need group messaging for DAO coordination. Create community groups with topic channels:
// src/lib/createDAOChat.ts
import chat from '@/lib/chat';
import TencentCloudChat from '@tencentcloud/chat';
export async function createDAOCommunity(params: {
daoName: string;
description: string;
ownerAddress: string;
tokenGateContract?: string;
}) {
// Create community group (supports up to 100,000 members)
const result = await chat.createGroup({
type: TencentCloudChat.TYPES.GRP_COMMUNITY,
name: params.daoName,
introduction: params.description,
ownerID: params.ownerAddress,
groupCustomField: [
{ key: 'tokenGate', value: params.tokenGateContract || '' },
{ key: 'type', value: 'dao-community' },
],
});
const groupID = result.data.group.groupID;
// Create topic channels within community
await chat.createTopicInCommunity({
groupID,
topicName: 'general',
});
await chat.createTopicInCommunity({
groupID,
topicName: 'governance',
});
await chat.createTopicInCommunity({
groupID,
topicName: 'announcements',
});
return { groupID };
}Step 4: Video Calls with TRTC
Video calling is what separates serious web3 social media from simple messaging apps. TRTC Video Call provides:
- Ultra-low latency (< 300ms globally across 2,800+ edge nodes)
- Up to 300 participants per room
- Screen sharing, virtual backgrounds
- Works across web, iOS, Android, desktop
- Adaptive bitrate for varying network conditions
Initialize TRTC and Enter a Room
// src/lib/videoCall.ts
import TRTC from 'trtc-sdk-v5';
let trtcClient: TRTC | null = null;
export async function initVideoCall(params: {
roomId: number;
userId: string; // wallet address
userSig: string;
sdkAppId: number;
}) {
trtcClient = TRTC.create();
// Enter room
await trtcClient.enterRoom({
roomId: params.roomId,
sdkAppId: params.sdkAppId,
userId: params.userId,
userSig: params.userSig,
scene: 'rtc', // real-time communication mode
});
// Start local camera
await trtcClient.startLocalVideo({
view: 'local-video', // DOM element ID
option: {
profile: '1080p',
mirror: true,
},
});
// Start microphone
await trtcClient.startLocalAudio({
option: { profile: 'speech' },
});
return trtcClient;
}
export async function leaveCall() {
if (!trtcClient) return;
await trtcClient.stopLocalVideo();
await trtcClient.stopLocalAudio();
await trtcClient.exitRoom();
trtcClient.destroy();
trtcClient = null;
}Handle Remote Streams
// src/hooks/useVideoCall.ts
import { useEffect, useRef, useState } from 'react';
import TRTC from 'trtc-sdk-v5';
import { initVideoCall, leaveCall } from '@/lib/videoCall';
export function useVideoCall(roomId: number, walletAddress: string) {
const [remoteUsers, setRemoteUsers] = useState<string[]>([]);
const [isCameraOn, setIsCameraOn] = useState(true);
const [isMicOn, setIsMicOn] = useState(true);
const clientRef = useRef<TRTC | null>(null);
useEffect(() => {
if (!walletAddress || !roomId) return;
const start = async () => {
const { userSig } = await fetch('/api/trtc/usersig', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userID: walletAddress }),
}).then(r => r.json());
const client = await initVideoCall({
roomId,
userId: walletAddress,
userSig,
sdkAppId: Number(process.env.NEXT_PUBLIC_TRTC_SDK_APP_ID),
});
clientRef.current = client;
// Handle remote user video available
client.on(TRTC.EVENT.REMOTE_VIDEO_AVAILABLE, (event: any) => {
const { userId } = event;
setRemoteUsers(prev => [...new Set([...prev, userId])]);
// Play remote video in corresponding DOM element
client.startRemoteVideo({
userId,
view: `remote-video-${userId}`,
streamType: TRTC.TYPE.STREAM_TYPE_MAIN,
});
});
// Handle remote user leaving
client.on(TRTC.EVENT.REMOTE_USER_LEAVE, (event: any) => {
setRemoteUsers(prev => prev.filter(id => id !== event.userId));
});
};
start();
return () => { leaveCall(); };
}, [roomId, walletAddress]);
const toggleCamera = async () => {
if (!clientRef.current) return;
if (isCameraOn) {
await clientRef.current.stopLocalVideo();
} else {
await clientRef.current.startLocalVideo({
view: 'local-video',
option: { profile: '1080p' },
});
}
setIsCameraOn(!isCameraOn);
};
const toggleMic = async () => {
if (!clientRef.current) return;
if (isMicOn) {
await clientRef.current.stopLocalAudio();
} else {
await clientRef.current.startLocalAudio({ option: { profile: 'speech' } });
}
setIsMicOn(!isMicOn);
};
return { remoteUsers, isCameraOn, isMicOn, toggleCamera, toggleMic };
}Video Call UI Component
// src/components/VideoCall.tsx
'use client';
import { useVideoCall } from '@/hooks/useVideoCall';
import { useAccount } from 'wagmi';
function formatAddress(addr: string) {
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}
export function VideoCall({ roomId }: { roomId: number }) {
const { address } = useAccount();
const { remoteUsers, isCameraOn, isMicOn, toggleCamera, toggleMic } = useVideoCall(roomId, address!);
return (
<div className="flex flex-col h-full bg-gray-900 rounded-xl p-4">
{/* Video Grid */}
<div className="flex-1 grid grid-cols-2 gap-4">
{/* Local video */}
<div className="relative aspect-video bg-gray-800 rounded-lg overflow-hidden">
<div id='local-video' className="w-full h-full" />
<span className="absolute bottom-2 left-2 text-white text-sm bg-black/50 px-2 py-1 rounded">
{formatAddress(address!)} (You)
</span>
</div>
{/* Remote videos */}
{remoteUsers.map(userId => (
<div key={userId} className="relative aspect-video bg-gray-800 rounded-lg overflow-hidden">
<div id={`remote-video-${userId}`} className="w-full h-full" />
<span className="absolute bottom-2 left-2 text-white text-sm bg-black/50 px-2 py-1 rounded">
{formatAddress(userId)}
</span>
</div>
))}
</div>
{/* Controls */}
<div className="flex justify-center gap-4 mt-4">
<button
onClick={toggleCamera}
className={`px-4 py-2 rounded-full ${isCameraOn ? 'bg-gray-700' : 'bg-red-600'} text-white`}
>
{isCameraOn ? 'Camera On' : 'Camera Off'}
</button>
<button
onClick={toggleMic}
className={`px-4 py-2 rounded-full ${isMicOn ? 'bg-gray-700' : 'bg-red-600'} text-white`}
>
{isMicOn ? 'Mic On' : 'Mic Off'}
</button>
<button
onClick={() => leaveCall()}
className="px-4 py-2 rounded-full bg-red-600 text-white"
>
Leave
</button>
</div>
</div>
);
}NFT-Gated Video Room Access
Before allowing users into a video room, verify NFT ownership:
// src/lib/nftGate.ts
import { createPublicClient, http, parseAbi } from 'viem';
import { polygon } from 'viem/chains';
const client = createPublicClient({
chain: polygon,
transport: http(),
});
const ERC721_ABI = parseAbi([
'function balanceOf(address owner) view returns (uint256)',
]);
export async function verifyNFTAccess(
walletAddress: `0x${string}`,
nftContractAddress: `0x${string}`,
requiredBalance: bigint = 1n
): Promise<boolean> {
const balance = await client.readContract({
address: nftContractAddress,
abi: ERC721_ABI,
functionName: 'balanceOf',
args: [walletAddress],
});
return balance >= requiredBalance;
}
// Usage before joining a video room
export async function joinGatedVideoRoom(
walletAddress: `0x${string}`,
nftContract: `0x${string}`,
roomId: number,
sdkAppId: number,
userSig: string
) {
const hasAccess = await verifyNFTAccess(walletAddress, nftContract);
if (!hasAccess) {
throw new Error(`Access denied: wallet ${walletAddress} does not hold required NFT`);
}
// Access verified — proceed to enter room
return initVideoCall({ roomId, userId: walletAddress, userSig, sdkAppId });
}Step 5: Community Voice Rooms
Voice rooms (like Twitter Spaces or Clubhouse) are the killer feature for web3 social networks. They enable:
- DAO town halls and governance discussions
- NFT project AMAs with hundreds of listeners
- Trading signal rooms with real-time commentary
- Community-building at scale (up to 100,000 listeners)
Voice Room Architecture
Voice rooms differ from video calls in key ways:
- Roles: Host, Speaker, Listener (hand-raise to speak)
- Scale: Hundreds of listeners with a few speakers
- No video: Audio-only reduces bandwidth, increases accessibility
- Persistent: Rooms can stay open for hours
Create a Voice Room with TRTC
// src/lib/voiceRoom.ts
import TRTC from 'trtc-sdk-v5';
export type VoiceRoomRole = 'host' | 'speaker' | 'listener';
export interface VoiceRoomConfig {
roomId: number;
userId: string;
role: VoiceRoomRole;
sdkAppId: number;
userSig: string;
}
export class VoiceRoomService {
private trtc: TRTC | null = null;
private currentRole: VoiceRoomRole = 'listener';
async createOrJoinRoom(config: VoiceRoomConfig) {
this.trtc = TRTC.create();
this.currentRole = config.role;
// Use 'live' scene which supports anchor/audience roles
await this.trtc.enterRoom({
roomId: config.roomId,
sdkAppId: config.sdkAppId,
userId: config.userId,
userSig: config.userSig,
scene: 'live',
role: config.role === 'listener' ? 'audience' : 'anchor',
});
// If host or speaker, start publishing audio
if (config.role !== 'listener') {
await this.trtc.startLocalAudio({
option: { profile: 'speech' },
});
}
// Set up remote audio handling (auto-plays)
this.trtc.on(TRTC.EVENT.REMOTE_AUDIO_AVAILABLE, (event: any) => {
console.log(`${event.userId} is now speaking`);
});
this.trtc.on(TRTC.EVENT.REMOTE_USER_LEAVE, (event: any) => {
console.log(`${event.userId} left the room`);
});
return this.trtc;
}
// Listener becomes speaker (after host approves hand-raise)
async becameSpeaker() {
if (!this.trtc || this.currentRole !== 'listener') return;
await this.trtc.switchRole('anchor');
await this.trtc.startLocalAudio({
option: { profile: 'speech' },
});
this.currentRole = 'speaker';
}
// Speaker steps down to listener
async stepDown() {
if (!this.trtc || this.currentRole === 'listener') return;
await this.trtc.stopLocalAudio();
await this.trtc.switchRole('audience');
this.currentRole = 'listener';
}
// Toggle mute (for speakers/host)
async toggleMute(muted: boolean) {
if (!this.trtc || this.currentRole === 'listener') return;
if (muted) {
await this.trtc.stopLocalAudio();
} else {
await this.trtc.startLocalAudio({ option: { profile: 'speech' } });
}
}
// Leave room
async leaveRoom() {
if (!this.trtc) return;
if (this.currentRole !== 'listener') {
await this.trtc.stopLocalAudio();
}
await this.trtc.exitRoom();
this.trtc.destroy();
this.trtc = null;
}
getRole() {
return this.currentRole;
}
}Token-Gated Voice Room Manager
// src/lib/voiceRoomManager.ts
import { ethers } from 'ethers';
import { VoiceRoomService, VoiceRoomConfig } from './voiceRoom';
interface TokenGatedRoom {
roomId: number;
title: string;
requiredToken: string; // ERC-721 or ERC-20 contract address
minBalance: bigint;
speakers: string[];
listeners: string[];
}
export class VoiceRoomManager {
private rooms: Map<number, TokenGatedRoom> = new Map();
private provider: ethers.BrowserProvider;
constructor(provider: ethers.BrowserProvider) {
this.provider = provider;
}
async createTokenGatedRoom(params: {
title: string;
requiredToken: string;
minBalance: bigint;
creatorConfig: Omit<VoiceRoomConfig, 'role'>;
}): Promise<TokenGatedRoom> {
const room: TokenGatedRoom = {
roomId: params.creatorConfig.roomId,
title: params.title,
requiredToken: params.requiredToken,
minBalance: params.minBalance,
speakers: [params.creatorConfig.userId],
listeners: [],
};
this.rooms.set(room.roomId, room);
// Creator joins as host
const voiceService = new VoiceRoomService();
await voiceService.createOrJoinRoom({
...params.creatorConfig,
role: 'host',
});
return room;
}
async joinRoom(roomId: number, userId: string, userSig: string, sdkAppId: number) {
const room = this.rooms.get(roomId);
if (!room) throw new Error('Room not found');
// Verify token ownership before allowing entry
const hasAccess = await this.verifyTokenGate(userId, room.requiredToken, room.minBalance);
if (!hasAccess) throw new Error('Insufficient token balance for this room');
room.listeners.push(userId);
const voiceService = new VoiceRoomService();
await voiceService.createOrJoinRoom({
roomId,
userId,
role: 'listener',
sdkAppId,
userSig,
});
return voiceService;
}
private async verifyTokenGate(
userAddress: string,
tokenContract: string,
minBalance: bigint
): Promise<boolean> {
const erc721ABI = ['function balanceOf(address) view returns (uint256)'];
const contract = new ethers.Contract(tokenContract, erc721ABI, this.provider);
const balance = await contract.balanceOf(userAddress);
return balance >= minBalance;
}
}Voice Room UI Component
// src/components/VoiceRoom.tsx
'use client';
import { useState, useEffect } from 'react';
import { VoiceRoomService } from '@/lib/voiceRoom';
import { useAccount } from 'wagmi';
interface Participant {
userId: string;
role: 'host' | 'speaker' | 'listener';
isMuted: boolean;
}
export function VoiceRoom({
roomId,
roomName,
sdkAppId,
userSig,
}: {
roomId: number;
roomName: string;
sdkAppId: number;
userSig: string;
}) {
const { address } = useAccount();
const [participants, setParticipants] = useState<Participant[]>([]);
const [myRole, setMyRole] = useState<'host' | 'speaker' | 'listener'>('listener');
const [isMuted, setIsMuted] = useState(false);
const [voiceService] = useState(() => new VoiceRoomService());
useEffect(() => {
if (!address) return;
voiceService.createOrJoinRoom({
roomId,
userId: address,
role: myRole,
sdkAppId,
userSig,
});
return () => { voiceService.leaveRoom(); };
}, [roomId, address]);
const handleMuteToggle = async () => {
await voiceService.toggleMute(!isMuted);
setIsMuted(!isMuted);
};
const handleBecameSpeaker = async () => {
await voiceService.becameSpeaker();
setMyRole('speaker');
};
return (
<div className="bg-gray-900 rounded-xl p-6 text-white">
<header className="mb-6">
<h2 className="text-xl font-bold">{roomName}</h2>
<span className="text-gray-400">{participants.length} in room</span>
</header>
{/* Speakers */}
<div className="grid grid-cols-4 gap-4 mb-6">
{participants
.filter(p => p.role === 'host' || p.role === 'speaker')
.map(p => (
<div key={p.userId} className="text-center">
<div className="w-16 h-16 rounded-full bg-blue-600 mx-auto flex items-center justify-center">
{p.userId.slice(2, 4).toUpperCase()}
</div>
<span className="text-sm mt-1 block">{p.userId.slice(0, 8)}...</span>
{p.isMuted && <span className="text-xs text-red-400">muted</span>}
</div>
))}
</div>
{/* Listeners */}
<div className="border-t border-gray-700 pt-4">
<h3 className="text-sm text-gray-400 mb-3">Listeners</h3>
<div className="flex flex-wrap gap-2">
{participants
.filter(p => p.role === 'listener')
.map(p => (
<div key={p.userId} className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center text-xs">
{p.userId.slice(2, 4)}
</div>
))}
</div>
</div>
{/* Controls */}
<div className="flex justify-center gap-4 mt-6">
{myRole === 'listener' && (
<button onClick={handleBecameSpeaker} className="px-4 py-2 bg-purple-600 rounded-full">
Request to Speak
</button>
)}
{myRole !== 'listener' && (
<button onClick={handleMuteToggle} className={`px-4 py-2 rounded-full ${isMuted ? 'bg-red-600' : 'bg-gray-700'}`}>
{isMuted ? 'Unmute' : 'Mute'}
</button>
)}
<button onClick={() => voiceService.leaveRoom()} className="px-4 py-2 bg-gray-700 rounded-full">
Leave
</button>
</div>
</div>
);
}Step 6: NFT-Gated Communities
NFT-gated communities are the core value proposition of web3 social media website development. Members prove ownership of specific NFTs to access exclusive content, channels, and features — creating real utility for digital assets.
Community Membership Smart Contract
// contracts/CommunityMembership.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract CommunityMembership is ERC721, Ownable {
uint256 private _nextTokenId;
uint256 public membershipPrice;
uint256 public maxMembers;
mapping(uint256 => uint8) public memberTier; // 0=basic, 1=premium, 2=VIP
event MemberJoined(address indexed member, uint256 tokenId, uint8 tier);
event TierUpgraded(uint256 tokenId, uint8 newTier);
constructor(
string memory name,
string memory symbol,
uint256 _price,
uint256 _maxMembers
) ERC721(name, symbol) Ownable(msg.sender) {
membershipPrice = _price;
maxMembers = _maxMembers;
}
function joinCommunity() external payable {
require(msg.value >= membershipPrice, "Insufficient payment");
require(_nextTokenId < maxMembers, "Community is full");
uint256 tokenId = _nextTokenId++;
_safeMint(msg.sender, tokenId);
memberTier[tokenId] = 0; // Basic tier
emit MemberJoined(msg.sender, tokenId, 0);
}
function upgradeTier(uint256 tokenId, uint8 newTier) external payable {
require(ownerOf(tokenId) == msg.sender, "Not token owner");
require(newTier > memberTier[tokenId], "Can only upgrade");
memberTier[tokenId] = newTier;
emit TierUpgraded(tokenId, newTier);
}
function isMember(address account) external view returns (bool) {
return balanceOf(account) > 0;
}
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
}Community Access Controller
// src/lib/communityAccess.ts
import { createPublicClient, http, parseAbi } from 'viem';
import { polygon } from 'viem/chains';
import chat from '@/lib/chat';
import TencentCloudChat from '@tencentcloud/chat';
const client = createPublicClient({
chain: polygon,
transport: http(process.env.NEXT_PUBLIC_POLYGON_RPC_URL),
});
const MEMBERSHIP_ABI = parseAbi([
'function balanceOf(address owner) view returns (uint256)',
'function isMember(address account) view returns (bool)',
'function memberTier(uint256 tokenId) view returns (uint8)',
]);
export class CommunityAccessController {
async verifyMembership(
userAddress: `0x${string}`,
contractAddress: `0x${string}`
): Promise<boolean> {
const isMember = await client.readContract({
address: contractAddress,
abi: MEMBERSHIP_ABI,
functionName: 'isMember',
args: [userAddress],
});
return isMember;
}
async grantCommunityAccess(params: {
userAddress: string;
communityGroupId: string;
}) {
// Add user to TRTC Chat community group
await chat.addGroupMember({
groupID: params.communityGroupId,
userIDList: [params.userAddress],
});
}
async createGatedCommunity(params: {
name: string;
description: string;
nftContract: string;
creatorAddress: string;
}) {
// Create community group in TRTC Chat
const result = await chat.createGroup({
type: TencentCloudChat.TYPES.GRP_COMMUNITY,
name: params.name,
introduction: params.description,
ownerID: params.creatorAddress,
groupCustomField: [
{ key: 'nftGate', value: params.nftContract },
{ key: 'type', value: 'nft-gated' },
],
});
const groupID = result.data.group.groupID;
// Create topic channels within community
await chat.createTopicInCommunity({
groupID,
topicName: 'general',
});
await chat.createTopicInCommunity({
groupID,
topicName: 'holders-only',
});
await chat.createTopicInCommunity({
groupID,
topicName: 'governance',
});
return groupID;
}
}Community Join Flow UI
// src/components/JoinCommunity.tsx
'use client';
import { useState } from 'react';
import { useAccount } from 'wagmi';
import { CommunityAccessController } from '@/lib/communityAccess';
export function JoinCommunity({
communityId,
nftContract,
communityName,
}: {
communityId: string;
nftContract: `0x${string}`;
communityName: string;
}) {
const { address } = useAccount();
const [status, setStatus] = useState<'idle' | 'verifying' | 'success' | 'denied'>('idle');
const handleJoin = async () => {
if (!address) return;
setStatus('verifying');
const controller = new CommunityAccessController();
const hasAccess = await controller.verifyMembership(address, nftContract);
if (hasAccess) {
await controller.grantCommunityAccess({
userAddress: address,
communityGroupId: communityId,
});
setStatus('success');
} else {
setStatus('denied');
}
};
return (
<div className="p-6 border rounded-xl bg-white shadow-sm">
<h3 className="text-xl font-bold">{communityName}</h3>
<p className="text-gray-500 mt-1">NFT-gated community</p>
{status === 'idle' && (
<button onClick={handleJoin} className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Verify NFT & Join
</button>
)}
{status === 'verifying' && <p className="mt-4 text-gray-600">Verifying NFT ownership...</p>}
{status === 'success' && <p className="mt-4 text-green-600 font-medium">Welcome to the community!</p>}
{status === 'denied' && (
<div className="mt-4">
<p className="text-red-600">You don't own the required NFT.</p>
<a
href={`https://opensea.io/assets/matic/${nftContract}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline mt-1 inline-block"
>
Get one on OpenSea
</a>
</div>
)}
</div>
);
}Step 7: Social Token Rewards System
Incentivize participation with a token reward system. Users earn tokens for messaging, joining calls, and contributing to communities:
// contracts/SocialToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract SocialToken is ERC20, Ownable {
mapping(address => uint256) public lastClaimTime;
uint256 public constant DAILY_REWARD = 10 * 10**18;
event RewardClaimed(address indexed user, uint256 amount);
event CreatorRewarded(address indexed creator, uint256 amount);
constructor() ERC20("Social Token", "SOCIAL") Ownable(msg.sender) {
_mint(msg.sender, 1_000_000 * 10**18); // initial supply
}
// Reward users for daily activity
function claimDailyReward() external {
require(
block.timestamp - lastClaimTime[msg.sender] >= 1 days,
"Already claimed today"
);
lastClaimTime[msg.sender] = block.timestamp;
_mint(msg.sender, DAILY_REWARD);
emit RewardClaimed(msg.sender, DAILY_REWARD);
}
// Platform rewards for content creation
function rewardCreator(address creator, uint256 amount) external onlyOwner {
_mint(creator, amount);
emit CreatorRewarded(creator, amount);
}
}Integrate Rewards with Chat Activity
// src/lib/rewardTracker.ts
import { ethers } from 'ethers';
export class RewardTracker {
private activityCounts: Map<string, number> = new Map();
private REWARD_THRESHOLD = 10; // messages before eligible for reward
trackActivity(userId: string) {
const current = this.activityCounts.get(userId) || 0;
this.activityCounts.set(userId, current + 1);
}
async checkAndDistributeReward(userId: string, signer: ethers.Signer) {
const count = this.activityCounts.get(userId) || 0;
if (count < this.REWARD_THRESHOLD) return false;
// Reset counter
this.activityCounts.set(userId, 0);
// Distribute on-chain reward
const contract = new ethers.Contract(
SOCIAL_TOKEN_ADDRESS,
['function rewardCreator(address, uint256)'],
signer
);
await contract.rewardCreator(userId, ethers.parseEther('5'));
return true;
}
}Accelerate Development with TRTC MCP Server
Building a web3 social app involves many TRTC API calls — room management, user credentials, message routing, moderation. The TRTC MCP (Model Context Protocol) server lets you use AI assistants to generate and debug TRTC integration code faster.
Set Up TRTC MCP
npx -y @anthropic-ai/claude-code mcp add trtc -- npx -y @anthropic-ai/mcp-remote https://mcp.trtc.io/sseOnce configured, your AI coding assistant can:
- Generate
userSigcredentials for testing - Scaffold TRTC room management APIs
- Debug audio/video issues with TRTC diagnostic tools
- Auto-generate webhook handlers for chat callbacks
- Query TRTC documentation contextually
- Generate complete feature implementations from descriptions
Example MCP Workflow
Ask your AI assistant:
"Create an API route that generates a TRTC room for a video call between two wallet addresses, with a 1-hour expiry and NFT-gate verification."
The MCP server provides real-time access to TRTC documentation, code samples, and API references. It generates production-ready code that works with your web3 architecture.
Why MCP Matters for Web3 Social Development
| Without MCP | With MCP |
|---|---|
| Read docs manually, copy-paste examples | Ask AI to generate specific integration code |
| Debug by trial and error | Get contextual error explanations |
| Search Stack Overflow for edge cases | Get SDK-specific solutions instantly |
| Days to integrate new features | Hours with AI-generated implementations |
Web3 social media app development combines multiple complex systems: blockchain, real-time communication, decentralized storage. Each has its own SDK, API patterns, and gotchas. The MCP server bridges the knowledge gap — you describe what you want in natural language, and get production-ready TRTC code that works with your web3 architecture.
Deployment and Scaling
Infrastructure Stack
| Component | Service | Why |
|---|---|---|
| Frontend | Vercel / Cloudflare Pages | Edge deployment, fast globally |
| API Backend | Next.js API Routes (Vercel) | Serverless, auto-scaling |
| Chat & Video | TRTC | 99.99% uptime, global edge nodes |
| Smart Contracts | Polygon (low gas) | Sub-cent transactions for social actions |
| Storage | IPFS via Pinata | Decentralized, persistent content |
| Database | Supabase | User sessions, indexing, real-time subscriptions |
Deployment Checklist
# 1. Deploy smart contracts to Polygon
npx hardhat deploy --network polygon
npx hardhat verify --network polygon $CONTRACT_ADDRESS
# 2. Set environment variables on Vercel
vercel env add TRTC_SDK_APP_ID
vercel env add TRTC_SECRET_KEY
vercel env add NEXT_PUBLIC_WC_PROJECT_ID
vercel env add POLYGON_RPC_URL
vercel env add PINATA_JWT
vercel env add SUPABASE_URL
vercel env add SUPABASE_ANON_KEY
# 3. Deploy frontend
vercel deploy --prod
# 4. Configure TRTC Console
# - Create application at console.trtc.io
# - Enable Chat + Video + Voice features
# - Set callback URLs for message eventsScaling Considerations
Chat scaling — TRTC Chat SDK handles millions of concurrent users out of the box. No need to build your own message queue or WebSocket infrastructure. Community groups support up to 100,000 members with topic channels.
Video scaling — TRTC's global network of 2,800+ edge nodes ensures < 300ms latency for video calls worldwide. For large voice rooms (1,000+ listeners), the live scene mode handles automatic stream distribution.
Blockchain scaling — Deploy on L2 (Polygon, Base, Arbitrum) for social interactions. Use L1 (Ethereum) only for high-value actions like profile NFT mints. Batch RPC calls using multicall contracts to check multiple NFT balances in one transaction.
Performance tips:
- Cache NFT ownership verification (5-10 minute TTL) — don't check on-chain every page load
- Use TRTC room callbacks instead of polling for user join/leave events
- Pre-warm TRTC SDK connections on app load, not on first interaction
- Compress media before IPFS upload
- Use Supabase real-time subscriptions for feed updates instead of polling
Cost Breakdown
Monthly Operating Costs
| Service | Free Tier | Growth (5K MAU) | Scale (50K MAU) |
|---|---|---|---|
| TRTC Chat | 100 DAU free | ~$200/mo | ~$1,500/mo |
| TRTC Video/Audio | 10,000 min free | ~$300/mo | ~$2,500/mo |
| Polygon Gas | ~$5/mo | ~$50/mo | ~$200/mo |
| IPFS Pinning (Pinata) | 500MB free | $20/mo | $200/mo |
| Supabase | Free tier | $25/mo | $300/mo |
| Vercel Hosting | Free tier | $20/mo | $150/mo |
| Total | ~$0 | ~$615/mo | ~$4,850/mo |
Compare this to building your own WebRTC infrastructure (easily $10,000+/month for engineering + servers at 50K MAU). TRTC's volume pricing discounts make scaling predictable.
Development Costs (MVP)
| Component | Build Time | Notes |
|---|---|---|
| Wallet auth + profiles | 1 week | SIWE + Profile NFT |
| Chat integration | 3-5 days | TRTC Chat SDK |
| Video calls | 3-5 days | TRTC Video SDK |
| Voice rooms | 1 week | Live scene + role management |
| Smart contracts | 1 week | Membership + rewards |
| UI/UX design | 2 weeks | Responsive, mobile-first |
| Testing + deployment | 1 week | E2E + contract audits |
| Total MVP | 6-8 weeks | With a team of 2-3 developers |
Complete Project Structure
web3-social/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── chat/[address]/page.tsx
│ │ ├── community/[id]/page.tsx
│ │ ├── room/[id]/page.tsx
│ │ └── api/
│ │ ├── auth/
│ │ │ ├── nonce/route.ts
│ │ │ └── verify/route.ts
│ │ ├── chat/usersig/route.ts
│ │ ├── trtc/usersig/route.ts
│ │ └── ipfs/upload/route.ts
│ ├── components/
│ │ ├── ChatWindow.tsx
│ │ ├── VideoCall.tsx
│ │ ├── VoiceRoom.tsx
│ │ └── JoinCommunity.tsx
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ ├── useChat.ts
│ │ ├── useVideoCall.ts
│ │ └── useProfile.ts
│ ├── lib/
│ │ ├── chat.ts
│ │ ├── videoCall.ts
│ │ ├── voiceRoom.ts
│ │ ├── voiceRoomManager.ts
│ │ ├── communityAccess.ts
│ │ ├── nftGate.ts
│ │ └── rewardTracker.ts
│ └── providers/
│ └── Web3Provider.tsx
├── contracts/
│ ├── SocialProfile.sol
│ ├── CommunityMembership.sol
│ └── SocialToken.sol
├── hardhat.config.ts
├── package.json
└── .env.localEnvironment Variables
# .env.local
NEXT_PUBLIC_WC_PROJECT_ID=your_walletconnect_project_id
NEXT_PUBLIC_TRTC_SDK_APP_ID=your_trtc_sdk_app_id
TRTC_SECRET_KEY=your_trtc_secret_key
NEXT_PUBLIC_PROFILE_CONTRACT=0x...
NEXT_PUBLIC_MEMBERSHIP_CONTRACT=0x...
NEXT_PUBLIC_TOKEN_CONTRACT=0x...
NEXT_PUBLIC_POLYGON_RPC_URL=https://polygon-rpc.com
PINATA_JWT=your_pinata_jwt
SUPABASE_URL=your_supabase_url
SUPABASE_ANON_KEY=your_supabase_keySecurity Best Practices
Smart Contract Security
- Audit before mainnet: Use OpenZeppelin Defender or Slither for automated scanning
- Upgradeable contracts: Use proxy patterns (UUPS) for patchable vulnerabilities
- Rate limiting: Prevent spam minting with cooldown periods
- Reentrancy guards: Use OpenZeppelin's
ReentrancyGuardon all payable functions
Communication Security
- TRTC encryption: All audio/video streams encrypted in transit (DTLS-SRTP)
- UserSig expiry: Generate short-lived UserSigs (24h-7d) and refresh on activity
- Message validation: Sanitize all user input before sending through Chat SDK
- Server-side verification: Never trust client-sent wallet addresses
// Always verify wallet ownership server-side before issuing TRTC credentials
// Bad: trusting client-sent address
app.post('/api/chat/token', (req, res) => {
const { address } = req.body; // Anyone can send any address!
const sig = generateUserSig(address);
res.json({ sig });
});
// Good: verify SIWE signature first
app.post('/api/chat/token', async (req, res) => {
const { message, signature } = req.body;
const siweMessage = new SiweMessage(message);
const { success, data } = await siweMessage.verify({ signature });
if (!success) return res.status(401).json({ error: 'Invalid signature' });
const sig = generateUserSig(data.address);
res.json({ sig });
});FAQ
What makes a web3 social app different from a regular social app?
Ownership. In a web3 social app, users own their identity (wallet), their content (NFTs/IPFS), and their social graph (on-chain). They can move between platforms without losing followers, and they earn tokens for contributing value. If the platform disappears, user data persists.
Do I need to build my own messaging infrastructure?
No. Building reliable real-time messaging from scratch requires years of engineering. Use TRTC Chat SDK for production-ready messaging with < 100ms delivery, offline sync, and support for millions of concurrent users. Focus your engineering time on the web3 differentiation layer — token-gating, NFTs, and governance.
How do I handle video calls in a decentralized app?
Video streaming requires centralized relay infrastructure for quality — pure P2P breaks down beyond 4 participants. TRTC Video Call provides the relay network (2,800+ global nodes) while your app handles authentication and access control via smart contracts. You get decentralized identity with centralized performance.
What blockchain should I use for a social app?
Use an L2 like Polygon, Base, or Arbitrum. Social interactions (likes, follows, tips) happen frequently and need sub-second confirmation with near-zero gas fees (< $0.01 per transaction). Deploy all social contracts on the same L2. Only use Ethereum L1 for high-value governance actions.
How do token-gated communities work technically?
When a user tries to join a community, your app calls balanceOf(userAddress) on the NFT contract. If they hold the required token, the backend grants access to the corresponding TRTC Chat group. Cache this check (5-10 minute TTL) and use blockchain Transfer events to revoke access when tokens are sold.
Can I add voice chat for gaming communities in my web3 social app?
Yes. For gaming-specific voice communication (low-latency push-to-talk, noise cancellation, 3D spatial audio), use GVoice — Tencent's gaming voice solution that powers games with billions of minutes monthly. For general social voice rooms (like Twitter Spaces), TRTC's standard audio SDK with the live scene mode is the right fit.
How much does it cost to start?
You can launch an MVP for under $500/month. TRTC offers free tiers (100 DAU for chat, 10,000 video minutes). Polygon gas costs are negligible. The major cost is development time, which the TRTC MCP server significantly reduces by generating integration code from natural language descriptions.
Is web3 social media website development harder than Web2?
The blockchain layer adds complexity (wallet UX, gas management, contract deployment), but the communication layer is actually simpler when using TRTC — you get chat, video, and voice rooms via SDK calls instead of building WebSocket servers, media servers, and STUN/TURN infrastructure. The net complexity is comparable to a standard Web2 social app.
Conclusion
Building a web3 social app is no longer experimental. The infrastructure exists: wallet auth is standardized (SIWE), L2s make on-chain social affordable, and TRTC provides the real-time communication layer that web3 native protocols can't match in reliability or performance.
The key architectural decision: don't try to decentralize everything. Put identity and ownership on-chain. Put real-time communication on battle-tested infrastructure. Put content on IPFS. Each layer uses the best tool for its job.
Start with the code in this tutorial:
- Connect a wallet with SIWE
- Mint a profile NFT
- Send a message via TRTC Chat
- Make a video call via TRTC Video
- Create a token-gated voice room
- Deploy your community smart contracts
You'll have a working web3 social network in a weekend — and a foundation that scales to millions of users without rebuilding the communication stack.


