Documentation Index
Fetch the complete documentation index at: https://www.dynamic.xyz/docs/llms.txt
Use this file to discover all available pages before exploring further.
What We’re Building
A Next.js application that lets users create an embedded wallet via social login, complete a KYC application, and receive a virtual Visa debit card funded by stablecoins (USDC). The complete flow covers:
- Social login and automatic wallet creation with Dynamic
- Gasless transactions via ZeroDev account abstraction
- Multi-step KYC form submitted to the Rain Card API
- Virtual card issuance, balance display, and USDC deposits
- JWT-authenticated API routes using Dynamic’s JWKS endpoint
The full example is on GitHub.
Prerequisites
- A Dynamic environment ID and API key
- A Rain Card API key and base URL
- Node.js 18+
Step 1: Install Dependencies
npm install @dynamic-labs/sdk-react-core @dynamic-labs/ethereum @dynamic-labs/ethereum-aa \
viem @tanstack/react-query react-hook-form zod jsonwebtoken jwks-rsa
The key packages:
| Package | Purpose |
|---|
@dynamic-labs/sdk-react-core | Embedded wallet UI and hooks |
@dynamic-labs/ethereum-aa | ZeroDev smart wallet connectors for gasless transactions |
viem | EVM transaction encoding (ERC-20 deposits) |
@tanstack/react-query | Async state management for on-chain reads |
jsonwebtoken / jwks-rsa | JWT verification for authenticated API routes |
NEXT_PUBLIC_DYNAMIC_ENV_ID=your-dynamic-environment-id
DYNAMIC_API_KEY=dyn_your_api_key
RAIN_API_KEY=your-rain-api-key
RAIN_API_BASE_URL=https://api.rain.com
GOOGLE_MAPS_API_KEY=your-google-maps-key # optional, for address autocomplete
Step 3: Set Up Dynamic with Account Abstraction
Wrap your app with DynamicContextProvider, registering both standard Ethereum connectors and ZeroDev smart wallet connectors. The smart wallet connectors enable gasless USDC deposits from the user’s embedded wallet to their card account.
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
DynamicContextProvider,
EthereumWalletConnectors,
DynamicUserProfile,
ZeroDevSmartWalletConnectors,
} from "@dynamic-labs/sdk-react-core";
import { redirect } from "next/navigation";
export default function Providers({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
return (
<DynamicContextProvider
theme="light"
settings={{
environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENV_ID!,
walletConnectors: [
EthereumWalletConnectors,
ZeroDevSmartWalletConnectors,
],
events: {
onLogout: () => redirect("/"),
},
}}
>
<QueryClientProvider client={queryClient}>
{children}
<DynamicUserProfile />
</QueryClientProvider>
</DynamicContextProvider>
);
}
Add this to your root layout:
import Providers from "@/lib/providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Step 4: Authenticate API Routes with Dynamic JWTs
Dynamic issues a JWT for every authenticated user. Protect your server-side API routes by verifying that token against Dynamic’s JWKS endpoint. This gives you access to the user’s verified credentials (wallet address, email) inside every handler.
lib/dynamic/dynamic-auth.ts
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
export type AuthenticatedUser = {
sub: string;
email?: string;
metadata?: Record<string, unknown>;
verified_credentials: Array<{
address?: string;
wallet_provider?: string;
}>;
};
const client = jwksClient({
jwksUri: "https://app.dynamicauth.com/api/v0/sdk/.well-known/jwks",
});
async function verifyToken(token: string): Promise<AuthenticatedUser> {
return new Promise((resolve, reject) => {
jwt.verify(token, (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}, {}, (err, decoded) => {
if (err) reject(err);
else resolve(decoded as AuthenticatedUser);
});
});
}
type AuthHandler = (
req: NextRequest,
context: { user: AuthenticatedUser }
) => Promise<NextResponse>;
export function withAuth(handler: AuthHandler) {
return async (req: NextRequest) => {
const authHeader = req.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const user = await verifyToken(authHeader.slice(7));
return handler(req, { user });
} catch {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
};
}
The JWKS URI must include your environment ID in production:
https://app.dynamicauth.com/api/v0/sdk/{environmentId}/.well-known/jwks
Step 5: Build the KYC Application Flow
The KYC form collects personal, address, and financial information across three steps. On submit, a server action POSTs to your /api/apply route, which forwards the data to Rain and automatically creates a card.
Application API route
import { NextRequest, NextResponse } from "next/server";
import { withAuth, AuthenticatedUser } from "@/lib/dynamic/dynamic-auth";
import { updateUserMetadata } from "@/lib/dynamic/methods";
import { createUserApplication, createCardForUser } from "@/lib/rain";
export const POST = withAuth(
async (req: NextRequest, { user }: { user: AuthenticatedUser }) => {
const body = await req.json();
// Pull the embedded wallet address from verified credentials
const walletAddress = user.verified_credentials.find(
(c) => c.wallet_provider === "embeddedWallet"
)?.address;
if (!walletAddress) {
return NextResponse.json({ error: "No wallet address found" }, { status: 400 });
}
if (!user.email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
// Submit KYC to Rain
const application = await createUserApplication({
firstName: body.firstName,
lastName: body.lastName,
birthDate: body.birthDate,
nationalId: body.nationalId.replace(/\D/g, ""),
countryOfIssue: "US",
email: user.email,
phoneCountryCode: "1",
phoneNumber: body.phoneNumber.replace(/\D/g, ""),
address: body.address,
walletAddress,
ipAddress: body.ipAddress,
occupation: body.occupation,
annualSalary: body.annualSalary,
accountPurpose: body.accountPurpose,
expectedMonthlyVolume: body.expectedMonthlyVolume,
isTermsOfServiceAccepted: true,
});
// Create a virtual card for the approved application
const card = await createCardForUser(application.id, {
type: "virtual",
limit: {
frequency: "per30DayPeriod",
amount: Number(body.expectedMonthlyVolume),
},
});
// Persist card reference in Dynamic user metadata
await updateUserMetadata(user.sub, { rainCard: card });
return NextResponse.json({ ok: true, card });
}
);
The Rain API auto-approves applications in test mode. In production, applications go through manual KYC review before a card can be created.
Calling the route from the client
Pass the Dynamic auth token as a Bearer token in every request to your API routes:
import { useDynamicContext } from "@dynamic-labs/sdk-react-core";
function useAuthFetch() {
const { authToken } = useDynamicContext();
return async (url: string, options: RequestInit = {}) => {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
};
}
// In your form submit handler:
const authFetch = useAuthFetch();
const res = await authFetch("/api/apply", {
method: "POST",
body: JSON.stringify(formData),
});
Step 6: Display Card Details and Balance
Once a card is created, its reference is stored in the user’s Dynamic metadata. Retrieve the card and check the Rain balance from separate API routes, then render them in a card component.
Balance API route
import { NextRequest, NextResponse } from "next/server";
import { withAuth, AuthenticatedUser } from "@/lib/dynamic/dynamic-auth";
import { userCreditBalance } from "@/lib/rain/methods";
export const GET = withAuth(
async (_req: NextRequest, { user }: { user: AuthenticatedUser }) => {
const rainCard = user.metadata?.rainCard as { userId: string } | undefined;
if (!rainCard) {
return NextResponse.json({ error: "No card found" }, { status: 404 });
}
const balance = await userCreditBalance(rainCard.userId);
return NextResponse.json({ balance });
}
);
Card details (encrypted)
Card numbers, CVVs, and expiry dates come back from Rain encrypted. Retrieve them per session and render client-side with a show/hide toggle:
app/api/card-details/route.ts
import { NextRequest, NextResponse } from "next/server";
import { withAuth, AuthenticatedUser } from "@/lib/dynamic/dynamic-auth";
import { cardEncryptedData } from "@/lib/rain/methods";
export const POST = withAuth(
async (req: NextRequest, { user }: { user: AuthenticatedUser }) => {
const { sessionId } = await req.json();
const rainCard = user.metadata?.rainCard as { cardId: string } | undefined;
if (!rainCard) {
return NextResponse.json({ error: "No card found" }, { status: 404 });
}
const data = await cardEncryptedData(rainCard.cardId, sessionId);
return NextResponse.json(data);
}
);
Step 7: Fund the Card with USDC
Users deposit USDC from their embedded wallet to the card’s on-chain account. Use viem’s writeContract to call the ERC-20 transfer function, with ZeroDev sponsoring gas so users don’t need ETH.
hooks/useDepositTokens.ts
import { useDynamicContext } from "@dynamic-labs/sdk-react-core";
import { isEthereumWallet } from "@dynamic-labs/ethereum";
import { parseUnits, erc20Abi } from "viem";
const RUSDC_ADDRESS = "0x..."; // rUSDC contract on your target network
const CARD_DEPOSIT_ADDRESS = "0x..."; // Rain card account address
export function useDepositTokens() {
const { primaryWallet } = useDynamicContext();
const deposit = async (amountUsd: number) => {
if (!primaryWallet || !isEthereumWallet(primaryWallet)) {
throw new Error("EVM wallet not connected");
}
const walletClient = await primaryWallet.getWalletClient();
const publicClient = await primaryWallet.getPublicClient();
// USDC has 6 decimals
const amount = parseUnits(String(amountUsd), 6);
const hash = await walletClient.writeContract({
address: RUSDC_ADDRESS,
abi: erc20Abi,
functionName: "transfer",
args: [CARD_DEPOSIT_ADDRESS, amount],
});
await publicClient.waitForTransactionReceipt({ hash });
return hash;
};
return { deposit };
}
Offer preset amounts (5,10, $25) and a custom input so users can quickly top up their card:
import { useState } from "react";
import { useDepositTokens } from "@/hooks/useDepositTokens";
const PRESETS = [5, 10, 25];
export function FundCard() {
const [amount, setAmount] = useState<number>(10);
const [loading, setLoading] = useState(false);
const { deposit } = useDepositTokens();
const handleDeposit = async () => {
setLoading(true);
try {
await deposit(amount);
} finally {
setLoading(false);
}
};
return (
<div>
<div>
{PRESETS.map((p) => (
<button key={p} onClick={() => setAmount(p)} aria-pressed={amount === p}>
${p}
</button>
))}
</div>
<input
type="number"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min={1}
/>
<button onClick={handleDeposit} disabled={loading}>
{loading ? "Depositing..." : `Deposit $${amount}`}
</button>
</div>
);
}
Complete User Flow
1. User visits the app → sees a "Get Started" prompt
2. Social login (email, Google, Discord) → Dynamic creates an embedded wallet
3. User navigates to /apply → fills out 3-step KYC form
4. POST /api/apply → Rain API creates application + virtual card
5. Card reference saved to Dynamic user metadata
6. User navigates to /card → sees card details, wallet balance, card balance
7. User deposits USDC → ERC-20 transfer from wallet to card account (gasless via ZeroDev)
8. User spends on the virtual Visa card → transactions appear in history
Supported Networks
| Network | Chain ID |
|---|
| Ethereum Sepolia (testnet) | 11155111 |
| Base Sepolia (testnet) | 84532 |