Building Mini Apps with Dynamic

Mini apps are lightweight, purpose-built web applications that can be embedded within social platforms, and they’re becoming increasingly popular. With the rise of Farcaster mini apps, developers can now create seamless wallet-connected experiences directly within these social platforms. Dynamic provides a powerful toolkit for integrating wallet functionality into your mini apps, allowing users to connect wallets, sign messages, and execute transactions without leaving the ecosystem.

In this guide, we’ll walk through building a mini app using Dynamic’s wallet infrastructure integrated with Farcaster mini apps to create a seamless wallet experience that works across multiple blockchain networks. If you want to take a quick look at the final code, check out the GitHub repository.

This guide demonstrates how to build a mini app that supports both Ethereum and Solana wallets using Dynamic’s multi-chain support.

Project Structure

Here’s the file structure we’ll be building:

my-dynamic-mini-app/
├── public/
│   └── .well-known/
│       └── farcaster.json
├── src/
│   ├── components/
│   │   ├── Methods.tsx
│   │   └── Methods.css
│   ├── wagmi.ts
│   ├── App.tsx
│   └── main.tsx
├── .env
└── package.json

Overview

Our mini app will allow users to:

  • Connect wallets via the Dynamic widget
  • Support both Ethereum and Solana networks
  • View wallet addresses and details
  • Sign messages with their connected wallets
  • Send transactions to other addresses

We’ll implement this using:

  • Dynamic SDK: For wallet connection and management
  • Farcaster Mini App SDK: For mini app integration and detection
  • Wagmi: For Ethereum blockchain interactions
  • Viem: For transaction formatting and handling
  • Solana Web3.js: For Solana blockchain interactions

Setup

1. Create a New Project

Let’s start by creating a new Dynamic project with React, Viem, Wagmi, and support for Ethereum and Solana:

npx create-dynamic-app my-app --framework react --library viem --wagmi true --chains ethereum,solana --pm npm my-dynamic-mini-app

2. Install Dependencies

Navigate to your project directory and install the additional Farcaster dependencies:

npm install @farcaster/frame-sdk @farcaster/frame-wagmi-connector

3. Configure Environment Variables

Create a .env file in the root of your project with your Dynamic environment ID:

VITE_DYNAMIC_ENVIRONMENT_ID=your-environment-id-here

You can find your Environment ID in the Dynamic dashboard under Developer Settings > SDK & API Keys.

4. Set Up Farcaster Mini App Configuration

First, create a directory structure for the Farcaster mini app configuration:

mkdir -p public/.well-known

Then create a farcaster.json file in the .well-known directory:

{
  "frame": {
    "version": "1",
    "name": "Dynamic Mini App",
    "iconUrl": "https://your-domain.com/image-light.png",
    "homeUrl": "https://your-domain.com",
    "splashImageUrl": "https://your-domain.com/logo-light.png",
    "splashBackgroundColor": "#000000",
    "subtitle": "Connect with Dynamic",
    "description": "A mini app that demonstrates wallet connections and transactions with Dynamic and Wagmi",
    "primaryCategory": "finance",
    "tags": ["wallet", "connect", "ethereum", "dynamic"],
    "ogTitle": "Dynamic Mini App",
    "ogDescription": "Connect wallets and perform transactions with Dynamic",
    "ogImageUrl": "https://your-domain.com/logo-light.png",
    "tagline": "Easy wallet connections & transactions"
  }
}

This manifest is required for publishing your mini app to the Farcaster ecosystem. For more details about configuring your application, refer to the Farcaster Mini App Publishing Guide.

Replace https://your-domain.com with your actual domain or hosting URL. The iconUrl, splashImageUrl, and ogImageUrl should point to images hosted on your server. Update the other fields as necessary to match your app’s branding and functionality.

5. Set Up Wagmi Configuration

Create a wagmi.ts file in your src directory:

src/wagmi.ts
import { farcasterFrame } from "@farcaster/frame-wagmi-connector";
import { http } from "viem";
import { baseSepolia } from "viem/chains";
import { createConfig } from "wagmi";

export const config = createConfig({
  chains: [baseSepolia],
  connectors: [farcasterFrame()],
  multiInjectedProviderDiscovery: false,
  transports: {
    [baseSepolia.id]: http(),
  },
});

declare module "wagmi" {
  interface Register {
    config: typeof config;
  }
}

This configuration sets up the Wagmi library with the Farcaster frame connector, which enables wallet connections within the Farcaster mini app environment. We’re using Base Sepolia as our example chain, so make sure to enable it in your Dynamic dashboard. If you prefer to use different chains, you can modify this configuration by adding them to the chains array and enabling them in your Dynamic dashboard.

6. Update HTML with Mini App Meta Tag

Add the Farcaster mini app meta tag to your index.html file:

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Dynamic Mini App</title>
    <meta
      name="fc:frame"
      content='{"version":"next","imageUrl":"https://your-domain.com/image-light.png","button":{"title":"Connect Wallet","action":{"type":"launch_frame","name":"Dynamic Mini App","url":"https://your-domain.com","splashImageUrl":"https://your-domain.com/logo-light.png","splashBackgroundColor":"#000000"}}}'
    />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

This is basic information required for a Farcaster mini app. Feel free to update the informating accordingly.

Now, let’s get into the implementation of the mini app. We’ll create a simple React application that integrates with Dynamic to manage wallet connections and transactions.

Implementation

Main App Component

Let’s start by setting up the main App.tsx component that will integrate Dynamic with our mini app:

src/App.tsx
import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";
import {
  DynamicContextProvider,
  DynamicWidget,
} from "@dynamic-labs/sdk-react-core";
import { SolanaWalletConnectors } from "@dynamic-labs/solana";
import { DynamicWagmiConnector } from "@dynamic-labs/wagmi-connector";
import sdk from "@farcaster/frame-sdk";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
import { useAccount, WagmiProvider } from "wagmi";
import { config } from "./wagmi";
import "./App.css";

const queryClient = new QueryClient();

function ConnectMenu() {
  const { isConnected, address } = useAccount();

  if (isConnected) {
    return (
      <div className="connect-menu">
        <div className="connect-label">Connected account:</div>
        <DynamicWidget />
        {address && <div className="wallet-address">{address}</div>}
      </div>
    );
  }

  return (
    <div className="connect-menu">
      <DynamicWidget />
    </div>
  );
}

function App() {
  useEffect(() => {
    const setupFarcaster = async () => {
      try {
        await sdk.actions.ready();
      } catch (error) {
        console.error("Error setting up Farcaster:", error);
      }
    };

    setupFarcaster();
  }, []);

  return (
    <DynamicContextProvider
      settings={{
        environmentId: import.meta.env.VITE_DYNAMIC_ENVIRONMENT_ID,
        walletConnectors: [EthereumWalletConnectors, SolanaWalletConnectors],
      }}
    >
      <WagmiProvider config={config}>
        <QueryClientProvider client={queryClient}>
          <DynamicWagmiConnector>
            <div className="app-container">
              <h1 className="app-title">Dynamic Mini App</h1>
              <div>
                <ConnectMenu />
              </div>
            </div>
          </DynamicWagmiConnector>
        </QueryClientProvider>
      </WagmiProvider>
    </DynamicContextProvider>
  );
}

export default App;

This App component sets up the foundation for our mini app by:

  • Configuring the Dynamic context provider with support for both Ethereum and Solana wallet connectors
  • Integrating Wagmi and React Query providers needed for blockchain interactions
  • Setting up the Farcaster mini app integration with the sdk.actions.ready() call
  • Creating a simple connect menu that shows the wallet address when connected
  • Handling the UI state based on the authentication status

The component structure ensures that all blockchain interactions are properly initialized before allowing the user to interact with wallet functions.

Adding Styles

For styling your mini app, you can either check out the file on the GitHub repository or create your own styles according to your design preferences.

Building Wallet Functionality

Now, let’s create a component that will handle wallet interactions. First, create a Methods.tsx file in the components folder:

src/components/Methods.tsx
import { isEthereumWallet } from "@dynamic-labs/ethereum";
import {
  useDynamicContext,
  useIsLoggedIn,
  useUserWallets,
} from "@dynamic-labs/sdk-react-core";
import { isSolanaWallet } from "@dynamic-labs/solana";
import { useEffect, useState } from "react";
import "./Methods.css";
import { sendTransaction, useWaitForTransactionReceipt } from "wagmi";

export default function DynamicMethods() {
  const isLoggedIn = useIsLoggedIn();
  const { sdkHasLoaded, primaryWallet, user } = useDynamicContext();
  const userWallets = useUserWallets();
  const [isLoading, setIsLoading] = useState(true);
  const [result, setResult] = useState<undefined | string>(undefined);
  const [error, setError] = useState<string | null>(null);
  const [recipientAddress, setRecipientAddress] = useState<string>("");
  const [amount, setAmount] = useState<string>("0.01");
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined);
  const { data: receiptData } = useWaitForTransactionReceipt({
    hash: txHash,
  });

  const isEthereum = primaryWallet && isEthereumWallet(primaryWallet);
  const isSolana = primaryWallet && isSolanaWallet(primaryWallet);

  // Helper to safely stringify objects with circular references
  const safeStringify = (obj: unknown): string => {
    const seen = new WeakSet();
    return JSON.stringify(
      obj,
      (_, value) => {
        if (typeof value === "object" && value !== null) {
          if (seen.has(value)) return "[Circular]";
          seen.add(value);
        }
        return value;
      },
      2
    );
  };

  useEffect(() => {
    if (sdkHasLoaded && isLoggedIn && primaryWallet) {
      setIsLoading(false);
    } else {
      setIsLoading(true);
    }
  }, [sdkHasLoaded, isLoggedIn, primaryWallet]);

  useEffect(() => {
    if (receiptData) {
      setResult(
        `Transaction confirmed! Block number: ${receiptData.blockNumber}`
      );
      setIsLoading(false);
    }
  }, [receiptData]);

  function clearResult() {
    setResult(undefined);
    setError(null);
  }

  function showUser() {
    try {
      setError(null);
      setResult(safeStringify(user));
    } catch (err) {
      setError(
        err instanceof Error ? err.message : "Failed to stringify user data"
      );
      setResult(undefined);
    }
  }

  function showUserWallets() {
    try {
      setError(null);
      setResult(safeStringify(userWallets));
    } catch (err) {
      setError(
        err instanceof Error ? err.message : "Failed to stringify wallet data"
      );
      setResult(undefined);
    }
  }

  async function fetchEthereumPublicClient() {
    if (!isEthereum) return;
    try {
      setIsLoading(true);
      setError(null);
      const result = await primaryWallet.getPublicClient();
      setResult(safeStringify(result));
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error occurred");
      setResult(undefined);
    } finally {
      setIsLoading(false);
    }
  }

  async function fetchEthereumWalletClient() {
    if (!isEthereum) return;
    try {
      setIsLoading(true);
      setError(null);
      const result = await primaryWallet.getWalletClient();
      setResult(safeStringify(result));
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error occurred");
      setResult(undefined);
    } finally {
      setIsLoading(false);
    }
  }

  async function signEthereumMessage() {
    if (!isEthereum) return;
    try {
      setIsLoading(true);
      setError(null);
      const result = await primaryWallet.signMessage("Hello World");
      setResult(result);
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error occurred");
      setResult(undefined);
    } finally {
      setIsLoading(false);
    }
  }

  async function fetchSolanaConnection() {
    if (!isSolana) return;
    try {
      setIsLoading(true);
      setError(null);
      const result = await primaryWallet.getConnection();
      setResult(safeStringify(result));
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error occurred");
      setResult(undefined);
    } finally {
      setIsLoading(false);
    }
  }

  async function fetchSolanaSigner() {
    if (!isSolana) return;
    try {
      setIsLoading(true);
      setError(null);
      const result = await primaryWallet.getSigner();
      setResult(safeStringify(result));
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error occurred");
      setResult(undefined);
    } finally {
      setIsLoading(false);
    }
  }

  async function signSolanaMessage() {
    if (!isSolana) return;
    try {
      setIsLoading(true);
      setError(null);
      const result = await primaryWallet.signMessage("Hello World");
      setResult(safeStringify(result));
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error occurred");
      setResult(undefined);
    } finally {
      setIsLoading(false);
    }
  }

  async function handleEthereumTransaction() {
    if (!isEthereum) return;

    if (
      recipientAddress === "0x0000000000000000000000000000000000000000" ||
      !recipientAddress.startsWith("0x")
    ) {
      setError("Please enter a valid Ethereum recipient address");
      return;
    }

    try {
      setIsLoading(true);
      setError(null);
      setResult("Sending transaction...");

      sendTransaction(
        {
          to: recipientAddress as `0x${string}`,
          value: parseEther(amount),
        },
        {
          onSuccess: (data) => {
            setTxHash(data); // Store the transaction hash
            setResult(`Transaction submitted: ${data}`);
          },
          onError: (error) => {
            setError(`${error.message}`);
            setIsLoading(false);
          },
        }
      );
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error occurred");
      setResult(undefined);
      setIsLoading(false);
    }
  }

  async function handleSolanaTransaction() {
    if (!isSolana) return;

    if (!recipientAddress || recipientAddress.trim() === "") {
      setError("Please enter a valid Solana recipient address");
      return;
    }

    try {
      setIsLoading(true);
      setError(null);
      setResult("Preparing Solana transaction...");

      const connection = await primaryWallet.getConnection();
      const publicKey = primaryWallet.address;

      const fromKey = new PublicKey(publicKey);
      const toKey = new PublicKey(recipientAddress);
      const amountInLamports = LAMPORTS_PER_SOL * parseFloat(amount);
      const instructions = [
        SystemProgram.transfer({
          fromPubkey: fromKey,
          lamports: amountInLamports,
          toPubkey: toKey,
        }),
      ];

      const blockhash = await connection.getLatestBlockhash();
      const messageV0 = new TransactionMessage({
        instructions,
        payerKey: fromKey,
        recentBlockhash: blockhash.blockhash,
      }).compileToV0Message();

      const transferTransaction = new VersionedTransaction(messageV0);
      const signer = await primaryWallet.getSigner();
      setResult("Creating and signing transaction...");

      const result = await signer.signAndSendTransaction(
        transferTransaction as unknown as Parameters<
          typeof signer.signAndSendTransaction
        >[0]
      );

      setResult(`Solana transaction sent! Signature: ${result.signature}`);
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error occurred");
      setResult(undefined);
    } finally {
      setIsLoading(false);
    }
  }

  // Initialize recipient address with appropriate prefix based on wallet type
  useEffect(() => {
    if (isEthereum) {
      setRecipientAddress("0x");
    } else if (isSolana) {
      setRecipientAddress("");
    }
  }, [isEthereum, isSolana]);

  return (
    <>
      {!isLoading && (
        <div className="dynamic-methods">
          <div className="methods-container">
            <button className="btn btn-primary" onClick={showUser}>
              Fetch User
            </button>
            <button className="btn btn-primary" onClick={showUserWallets}>
              Fetch User Wallets
            </button>
            {isEthereum && (
              <>
                <button
                  type="button"
                  className="btn btn-primary"
                  onClick={fetchEthereumPublicClient}
                >
                  Fetch PublicClient
                </button>
                <button
                  type="button"
                  className="btn btn-primary"
                  onClick={fetchEthereumWalletClient}
                >
                  Fetch WalletClient
                </button>
                <button
                  type="button"
                  className="btn btn-primary"
                  onClick={signEthereumMessage}
                >
                  Sign Message
                </button>
              </>
            )}
            {isSolana && (
              <>
                <button
                  type="button"
                  className="btn btn-primary"
                  onClick={fetchSolanaConnection}
                >
                  Fetch Connection
                </button>
                <button
                  type="button"
                  className="btn btn-primary"
                  onClick={fetchSolanaSigner}
                >
                  Fetch Signer
                </button>
                <button
                  type="button"
                  className="btn btn-primary"
                  onClick={signSolanaMessage}
                >
                  Sign Message
                </button>
              </>
            )}
          </div>

          {(result || error) && (
            <div className="results-container">
              {error ? (
                <pre className="results-text error">{error}</pre>
              ) : (
                <pre className="results-text">
                  {result &&
                    (typeof result === "string" && result.startsWith("{")
                      ? JSON.stringify(JSON.parse(result), null, 2)
                      : result)}
                </pre>
              )}
            </div>
          )}

          {(result || error) && (
            <div className="clear-container">
              <button className="btn btn-primary" onClick={clearResult}>
                Clear
              </button>
            </div>
          )}

          {primaryWallet && (
            <div className="recipient-container">
              <h3>Send {isEthereum ? "Ethereum" : "Solana"} Transaction</h3>
              <div className="transaction-form">
                <div className="form-group">
                  <label htmlFor="recipient-address">Recipient Address</label>
                  <input
                    id="recipient-address"
                    type="text"
                    value={recipientAddress}
                    onChange={(e) => setRecipientAddress(e.target.value)}
                    placeholder={
                      isEthereum ? "Enter 0x... address" : "Enter SOL address..."
                    }
                    className="address-input"
                  />
                </div>
                <div className="form-group">
                  <label htmlFor="tx-amount">Amount</label>
                  <div className="input-group">
                    <input
                      id="tx-amount"
                      type="text"
                      value={amount}
                      onChange={(e) => setAmount(e.target.value)}
                      placeholder="0.01"
                      className="amount-input"
                    />
                    <span className="currency-label">
                      {isEthereum ? "ETH" : "SOL"}
                    </span>
                  </div>
                </div>
                <button
                  type="button"
                  className="send-btn"
                  onClick={
                    isEthereum ? handleEthereumTransaction : handleSolanaTransaction
                  }
                >
                  Send {isEthereum ? "ETH" : "SOL"}
                </button>
              </div>
            </div>
          )}
        </div>
      )}
    </>
  );
}

This DynamicMethods component handles the core wallet functionality for our mini app. It leverages several hooks from Dynamic’s SDK to access wallet and user information, and provides a simple interface for users to interact with their wallets.

Key Functions:

  • showUser: Displays the current user’s information from the useDynamicContext hook, including their ID, auth method, and other profile details
  • showUserWallets: Shows all connected wallets using the useUserWallets hook, providing details about addresses, networks, and wallet types
  • clearResult: Resets the display after viewing data
  • fetchEthereumPublicClient, fetchEthereumWalletClient, signEthereumMessage: Ethereum-specific functions to fetch public/wallet clients and sign messages
  • fetchSolanaConnection, fetchSolanaSigner, signSolanaMessage: Solana-specific functions to fetch connection/signer and sign messages
  • handleEthereumTransaction, handleSolanaTransaction: Functions to handle sending transactions on Ethereum and Solana, respectively

You can choose to style it as you like in Methods.css or if you want to use the same styles check out this file on Github.

App.tsx
import DynamicMethods from "./components/Methods";

// ... in the App component
<ConnectMenu />
<DynamicMethods />

This code shows how to import and use the DynamicMethods component within your main App component, placing it after the ConnectMenu component to create a logical user flow: first connect a wallet, then interact with wallet methods.

For CSS styling, check out the file in the GitHub repository, or feel free to create your own styles as needed.

Running the Mini App

You can now run your mini app locally:

npm run dev

The app will be available at http://localhost:5173 by default.

To try out the app for testing, you’ll need to expose your local server to the internet. You can use services like cloudflared or ngrok:

cloudflared tunnel --url http://localhost:5173

When using tools like Cloudflare Tunnel, you’ll need to update your Vite configuration to allow requests from the domain. Add the following to your vite.config.ts file:

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { nodePolyfills } from "vite-plugin-node-polyfills";

export default defineConfig({
  plugins: [
    react(),
    nodePolyfills({
      exclude: [],
      globals: {
        Buffer: true,
        global: true,
        process: true,
      },
      protocolImports: true,
    }),
  ],
  resolve: {
    alias: {},
  },
  server: {
    // Update this with your Cloudflare/ngrok domain
    allowedHosts: ["your-tunnel-domain.trycloudflare.com", "localhost"],
  },
});

Replace your-tunnel-domain.trycloudflare.com with your actual tunnel domain. The allowedHosts setting enables requests from specified domains to your development server, which is essential for testing with tunneling tools.

Once your app is running, copy the public URL provided by cloudflared or ngrok and add it in CORS Origins in your Dynamic dashboard under Developer Settings > CORS Origins. This step is crucial as it allows your mini app to communicate with the Dynamic backend.

Testing the Mini App

To test your mini app in the Farcaster ecosystem, you can use the mini app embed tool. This tool allows you to preview how your mini app would appear and function within Farcaster’s interface.

Conclusion

You’ve now built a complete mini app with Dynamic that works seamlessly with Farcaster! This application enables users to connect different types of wallets, sign messages, and send transactions all within a unified interface.

By leveraging Dynamic’s multi-chain support, your mini app works with both Ethereum and Solana wallets without requiring additional configuration. This flexibility allows your users to interact with their preferred wallets and networks, creating a more inclusive experience.

The mini app you’ve built demonstrates several key capabilities:

  • Wallet connection with Dynamic’s embedded widget
  • User identification and wallet information retrieval
  • Chain-specific functionality for both Ethereum and Solana
  • Message signing for authentication purposes
  • Transaction sending with appropriate validation

For additional help or to join the Dynamic community: