A minimal Next.js 16 + shadcn/ui + TypeScript starter for building Circles embedded miniapps. It wires up:
@aboutcircles/miniapp-sdk— host-injected wallet, transaction submission, message signing@aboutcircles/sdk— read-only Circles data (avatar lookup, profile, balances, trust graph)
pnpm install
pnpm devOpen http://localhost:3000. The dashboard renders with a sidebar of placeholder routes and a "Not connected" badge — that is expected outside the Circles host.
The Circles host runs your miniapp inside an iframe and pushes the wallet address to you — there is no "Connect" button click flow. The SDK exposes one subscription primitive:
import { onWalletChange } from '@aboutcircles/miniapp-sdk';
const unsubscribe = onWalletChange((address) => {
// address is the Safe address the user picked in the host, or null on disconnect.
});components/wallet/WalletProvider.tsx wraps this in a React context. Anywhere in a client component you can do:
import { useWallet } from '@/hooks/use-wallet';
const { address, isConnected, isMiniappHost } = useWallet();Running standalone (pnpm dev in a normal browser tab), isMiniappHost is false and the callback never fires — the UI stays disconnected. This is by design; the SDK ships with no fallback wallet because Circles miniapps are always wallet-injected by the host.
The Circles playground iframes any HTTPS URL you paste into it — no manifest, registration, or fork required.
- Deploy the app to a public HTTPS URL (Vercel preview deploys work great:
git push→ grab thehttps://*.vercel.appURL). - Open
https://circles.gnosis.io/playgroundand paste your deploy URL into the input, or openhttps://circles.gnosis.io/playground?url=<your-deploy-url>directly. - The host injects a Safe address, the badge in the header flips to a shortened address, and the Sign in /
sendTransactionsflows start working end-to-end.
next.config.ts ships with a Content-Security-Policy: frame-ancestors header pre-allowing *.gnosis.io (covers both circles.gnosis.io prod and circles-dev.gnosis.io dev hosts) and *.vercel.app. If you deploy to a different host, add it there.
For permanent placement in the host's catalog, open a PR against aboutcircles/CirclesMiniapps adding an entry to static/miniapps.json:
{
"slug": "your-app",
"name": "Your App",
"url": "https://your-app.example.com/",
"logo": "https://your-app.example.com/icon.svg",
"description": "One-liner.",
"tags": ["demo"],
"isHidden": false
}The host can ask the user's Safe to sign an arbitrary message via EIP-1271. The dashboard's Sign in card demonstrates the full round-trip:
'use client';
import { signMessage } from '@aboutcircles/miniapp-sdk';
const { signature, verified } = await signMessage(
'Sign in to my miniapp\nNonce: abc123',
);verified reflects whether the host already validated the signature against the user's Safe; you can re-verify server-side before issuing a session cookie. See components/wallet/SignInDemo.tsx.
The @aboutcircles/sdk package provides a higher-level, read-friendly client. The /profile route uses its consolidated profile view endpoint to pull avatar info, name, trust stats, and balances for the connected address in a single call:
'use client';
import { Sdk } from '@aboutcircles/sdk';
const sdk = new Sdk(); // defaults to Gnosis Chain mainnet
const view = await sdk.rpc.profile.getProfileView(address);
// → { avatarInfo?, profile?, trustStats, v2Balance?, v1Balance? }
if (view.avatarInfo?.cidV0) {
// optionally pull the full IPFS profile for richer fields (description, image)
const full = await sdk.rpc.profile.getProfileByCid(view.avatarInfo.cidV0);
}Why getProfileView and not sdk.getAvatar()? getAvatar() is the right call when you need a write-capable Avatar instance (trust, transfer, mint). For read-only lookups it can throw a misleading "Avatar not found" error even on valid avatars whose on-chain cidV0Digest is empty. getProfileView() is the read primitive and degrades gracefully — it returns avatarInfo: undefined for addresses that aren't Circles avatars.
For write flows, fall back to sdk.getAvatar(address) once the user has both a wallet and a registered avatar; the same object exposes balances, trust, history, transfer. See components/profile/ProfileLookup.tsx.
'use client';
import { sendTransactions } from '@aboutcircles/miniapp-sdk';
const txHashes = await sendTransactions([
{ to: '0x…', data: '0x…', value: '0' },
]);The host batches and signs through the user's Safe and returns the resulting tx hashes.
app/
layout.tsx Root: wraps every page in <WalletProvider><AppShell>
page.tsx Dashboard — connection card, sign-in demo, nav cards
profile/page.tsx Circles avatar lookup via @aboutcircles/sdk
actions/page.tsx Placeholder (where sendTransactions demos go)
globals.css Tailwind v4 + shadcn tokens (light only)
components/
layout/ AppShell, Header, Sidebar, NavCards
wallet/ WalletProvider, WalletStatus, ConnectionCard, SignInDemo
profile/ ProfileLookup (uses @aboutcircles/sdk)
ui/ shadcn-generated primitives
hooks/use-wallet.ts Re-export of useWallet
lib/
utils.ts cn() + shortenAddress()
nav.ts Sidebar nav items (single source of truth)
- Read-only flows (profile, balance, trust graph) — call
useWallet()to get the address, then drive@aboutcircles/sdkfrom a client component. SeeProfileLookupfor the pattern. - Write flows — keep
sendTransactions()calls inside'use client'components; build the calldata however you like (viem, ethers, or hand-encoded). - Authentication — call
signMessage()to sign a SIWE-style nonce; re-verify the signature on your backend. SeeSignInDemo. - New routes — drop a
page.tsxunderapp/<route>/and add an entry tolib/nav.tsto expose it in the sidebar.
| Command | What it does |
|---|---|
pnpm dev |
Start the dev server on :3000 |
pnpm build |
Production build |
pnpm start |
Run the built app |
pnpm lint |
ESLint |
- Both SDKs touch
window. They must be dynamically imported inside a client component'suseEffect—WalletProviderandProfileLookupalready do this. Don't import them at the top level of a server component or you'll seewindow is not definedduring build. - No connect button. If you find yourself adding one, you're working around the wrong problem — the host is the wallet UI.
getAvatarthrows for unregistered addresses. Most EOAs aren't Circles avatars. The/profilepage surfaces this as a friendly error; don't treat it as a bug.- Light mode only. The dark-mode CSS variables are stripped from
globals.css. If you re-add dark mode later, restore the.dark { … }block and the@custom-variant darkdirective, then addnext-themes. - Tailwind v4. No
tailwind.config.js; theme tokens live inapp/globals.cssunder@theme inline { … }.
- Embedded miniapps — official Circles docs — the host/iframe contract, lifecycle, postMessage protocol, and what's coming next. Start here if you want to understand why the SDK is shaped the way it is.
- Circles playground — paste a URL, iframe it as a miniapp
aboutcircles/CirclesMiniapps— host repo; submit a PR tostatic/miniapps.jsonfor marketplace listing@aboutcircles/miniapp-sdkon npm — host bridge (wallet, signing, transactions)@aboutcircles/sdkon npm — Circles data (avatars, profiles, balances, trust)aboutcircles/circles-groups-miniapp— the original (vanilla JS + Vite) reference miniapp this template draws from
MIT