Write data w/ managed signers

In this guide, we’ll take a look at how to integrate neynar managed signers with neynar in a next.js app. Managed signers allow you to take full control of the connection, including the branding on Warpcast and everything else!

For this guide, we'll go over:

  1. Creating a signer key for the user so they can sign in
  2. Storing the user's credentials in local storage
  3. Writing casts to Farcaster using the signer

Before we begin, you can access the complete source code for this guide on GitHub.

The simplest way to set up managed signers is by cloning the above repo! You can use the following commands to check it out:

npx degit https://github.com/neynarxyz/farcaster-examples/tree/main/managed-signers managed-signers
cd managed-signers
yarn 
yarn dev

Let's get started!

Setting up a next.js app

Creating a next app

We are going to need a frontend as well as a backend server, so we are going to use Next.js for this guide. Run the following command to create a new next.js app:

npx create-next-app managed-signers

Choose the configuration for your app and wait for the dependencies to install. Once they are installed, let's install the additional packages that we are going to need:

npm i @farcaster/hub-nodejs @neynar/nodejs-sdk axios qrcode.react viem
yarn add @farcaster/hub-nodejs @neynar/nodejs-sdk axios qrcode.react viem
pnpm add @farcaster/hub-nodejs @neynar/nodejs-sdk axios qrcode.react viem
bun add @farcaster/hub-nodejs @neynar/nodejs-sdk axios qrcode.react viem

Now, you can open the folder in your favourite code editor and we can start building!

Configuring env variables

Firstly, let's configure the env variables we will need. Create a new .env.local file and add these two variables:

NEYNAR_API_KEY=
FARCASTER_DEVELOPER_MNEMONIC=

The neynar api key should be the api key that you can view on your dashboard, and the mnemonic should be the mnemonic associated with the account which will be used to create the signers.

Creating API route for generating the signature

Create a new route.ts file in the src/app/api/signer folder and add the following:

import { getSignedKey } from "@/utils/getSignedKey";
import { NextResponse } from "next/server";

export async function POST() {
  try {
    const signedKey = await getSignedKey();

    return NextResponse.json(signedKey, {
      status: 200,
    });
  } catch (error) {
    return NextResponse.json({ error: "An error occurred" }, { status: 500 });
  }
}

This is just defining a get and a post request and calling a getSignedKey function but we haven't created that yet, so let's do that.

Create a new utils/getSignedKey.tsfile and add the following:

import neynarClient from "@/lib/neynarClient";
import { ViemLocalEip712Signer } from "@farcaster/hub-nodejs";
import { bytesToHex, hexToBytes } from "viem";
import { mnemonicToAccount } from "viem/accounts";
import { getFid } from "./getFid";

export const getSignedKey = async () => {
  const createSigner = await neynarClient.createSigner();
  const { deadline, signature } = await generate_signature(
    createSigner.public_key
  );

  if (deadline === 0 || signature === "") {
    throw new Error("Failed to generate signature");
  }

  const fid = await getFid();

  const signedKey = await neynarClient.registerSignedKey(
    createSigner.signer_uuid,
    fid,
    deadline,
    signature
  );

  return signedKey;
};

const generate_signature = async function (public_key: string) {
  if (typeof process.env.FARCASTER_DEVELOPER_MNEMONIC === "undefined") {
    throw new Error("FARCASTER_DEVELOPER_MNEMONIC is not defined");
  }

  const FARCASTER_DEVELOPER_MNEMONIC = process.env.FARCASTER_DEVELOPER_MNEMONIC;
  const FID = await getFid();

  const account = mnemonicToAccount(FARCASTER_DEVELOPER_MNEMONIC);
  const appAccountKey = new ViemLocalEip712Signer(account as any);

  // Generates an expiration date for the signature (24 hours from now).
  const deadline = Math.floor(Date.now() / 1000) + 86400;

  const uintAddress = hexToBytes(public_key as `0x${string}`);

  const signature = await appAccountKey.signKeyRequest({
    requestFid: BigInt(FID),
    key: uintAddress,
    deadline: BigInt(deadline),
  });

  if (signature.isErr()) {
    return {
      deadline,
      signature: "",
    };
  }

  const sigHex = bytesToHex(signature.value);

  return { deadline, signature: sigHex };
};

We are doing a couple of things here, so let's break it down.

We first use the neynarClient (yet to create) to create a signer, and then we use the appAccountKey.signKeyRequest function from the @farcaster/hub-nodejs package to create a sign key request. Finally, we use the registerSignedKey function from the neynarClient to return the signedKey.

Let's now initialise our neynarClient in a new lib/neynarClient.ts file like this:

import { NeynarAPIClient } from "@neynar/nodejs-sdk";

if (!process.env.NEYNAR_API_KEY) {
  throw new Error("Make sure you set NEYNAR_API_KEY in your .env file");
}

const neynarClient = new NeynarAPIClient(process.env.NEYNAR_API_KEY);

export default neynarClient;

We are also using another util function named getFid in the signature generation, so let's create a utils/getFid.ts file and create that as well:

import neynarClient from "@/lib/neynarClient";
import { mnemonicToAccount } from "viem/accounts";

export const getFid = async () => {
  if (!process.env.FARCASTER_DEVELOPER_MNEMONIC) {
    throw new Error("FARCASTER_DEVELOPER_MNEMONIC is not set.");
  }

  const account = mnemonicToAccount(process.env.FARCASTER_DEVELOPER_MNEMONIC);

  // Lookup user details using the custody address.
  const { user: farcasterDeveloper } =
    await neynarClient.lookupUserByCustodyAddress(account.address);

  return Number(farcasterDeveloper.fid);
};

We can use this api route on our front end to generate a signature and show the QR code/deep link to the user. So, head over to app/page.tsx and add the following:

"use client";

import axios from "axios";
import QRCode from "qrcode.react";
import { useState } from "react";
import styles from "./page.module.css";

interface FarcasterUser {
  signer_uuid: string;
  public_key: string;
  status: string;
  signer_approval_url?: string;
  fid?: number;
}

export default function Home() {
  const LOCAL_STORAGE_KEYS = {
    FARCASTER_USER: "farcasterUser",
  };
  const [loading, setLoading] = useState(false);
  const [farcasterUser, setFarcasterUser] = useState<FarcasterUser | null>(
    null
  );

  const handleSignIn = async () => {
    setLoading(true);
    await createAndStoreSigner();
    setLoading(false);
  };

  const createAndStoreSigner = async () => {
    try {
      const response = await axios.post("/api/signer");
      if (response.status === 200) {
        localStorage.setItem(LOCAL_STORAGE_KEYS.FARCASTER_USER, JSON.stringify(response.data));
        setFarcasterUser(response.data);
      }
    } catch (error) {
      console.error("API Call failed", error);
    }
  };

  return (
    <div className={styles.container}>
      {!farcasterUser?.status && (
        <button
          className={styles.btn}
          onClick={handleSignIn}
          disabled={loading}
        >
          {loading ? "Loading..." : "Sign in with farcaster"}
        </button>
      )}

      {farcasterUser?.status == "pending_approval" &&
        farcasterUser?.signer_approval_url && (
          <div className={styles.qrContainer}>
            <QRCode value={farcasterUser.signer_approval_url} />
            <div className={styles.or}>OR</div>
            <a
              href={farcasterUser.signer_approval_url}
              target="_blank"
              rel="noopener noreferrer"
              className={styles.link}
            >
              Click here to view the signer URL (on mobile)
            </a>
          </div>
        )}
    </div>
  );
}

Here, we show a button to sign in with farcaster in case the farcasterUser state is empty which it will be initially. And if the status is "pending_approval" then we display a QR code and a deep link for mobile view like this:

If you try scanning the QR code it will take you to Warpcast to sign into your app! But signing in won't do anything right now, so let's also handle that.

In the api/signer/route.ts file let's add a GET function as well to fetch the signer using the signer uuid like this:

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const signer_uuid = searchParams.get("signer_uuid");

  if (!signer_uuid) {
    return NextResponse.json(
      { error: "signer_uuid is required" },
      { status: 400 }
    );
  }

  try {
    const signer = await neynarClient.lookupSigner(signer_uuid);

    return NextResponse.json(signer, { status: 200 });
  } catch (error) {
    return NextResponse.json({ error: "An error occurred" }, { status: 500 });
  }
}

Let's use this route in the page.tsx file to fetch the signer and set it in local storage using some useEffects:

useEffect(() => {
    const storedData = localStorage.getItem(LOCAL_STORAGE_KEYS.FARCASTER_USER);
    if (storedData) {
      const user: FarcasterUser = JSON.parse(storedData);
      setFarcasterUser(user);
    }
  }, []);

  useEffect(() => {
    if (farcasterUser && farcasterUser.status === "pending_approval") {
      let intervalId: NodeJS.Timeout;

      const startPolling = () => {
        intervalId = setInterval(async () => {
          try {
            const response = await axios.get(
              `/api/signer?signer_uuid=${farcasterUser?.signer_uuid}`
            );
            const user = response.data as FarcasterUser;

            if (user?.status === "approved") {
              // store the user in local storage
              localStorage.setItem(
                LOCAL_STORAGE_KEYS.FARCASTER_USER,
                JSON.stringify(user)
              );

              setFarcasterUser(user);
              clearInterval(intervalId);
            }
          } catch (error) {
            console.error("Error during polling", error);
          }
        }, 2000);
      };

      const stopPolling = () => {
        clearInterval(intervalId);
      };

      const handleVisibilityChange = () => {
        if (document.hidden) {
          stopPolling();
        } else {
          startPolling();
        }
      };

      document.addEventListener("visibilitychange", handleVisibilityChange);

      // Start the polling when the effect runs.
      startPolling();

      // Cleanup function to remove the event listener and clear interval.
      return () => {
        document.removeEventListener(
          "visibilitychange",
          handleVisibilityChange
        );
        clearInterval(intervalId);
      };
    }
  }, [farcasterUser]);

Here, we are checking if the user has approved the TX and if they have approved it, we call the signer api and set the user details in the local storage. Let's also add a condition in the return statement to display the fid of the user if they are logged in:

 {farcasterUser?.status == "approved" && (
        <div className={styles.castSection}>
          <div className={styles.userInfo}>Hello {farcasterUser.fid} 👋</div>
        </div>
      )}

Allowing users to write casts

Let's now use the signer that we get from the user to publish casts from within the app. Create a new api/cast/route.ts file and add the following:

import neynarClient from "@/lib/neynarClient";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.json();

  try {
    const cast = await neynarClient.publishCast(body.signer_uuid, body.text);

    return NextResponse.json(cast, { status: 200 });
  } catch (error) {
    console.error(error);
    return NextResponse.json({ error: "An error occurred" }, { status: 500 });
  }
}

Here, I am using the publishCast function to publish a cast using the signer uuid and the text which I am getting from the body of the request.

Head back to the page.tsx file and add a handleCast function to handle the creation of casts like this:

const [text, setText] = useState<string>("");
const [isCasting, setIsCasting] = useState<boolean>(false);

const handleCast = async () => {
  setIsCasting(true);
  const castText = text.length === 0 ? "gm" : text;
  try {
    const response = await axios.post("/api/cast", {
      text: castText,
      signer_uuid: farcasterUser?.signer_uuid,
    });
    if (response.status === 200) {
      setText(""); // Clear the text field
      alert("Cast successful");
    }
  } catch (error) {
    console.error("Could not send the cast", error);
  } finally {
    setIsCasting(false); // Re-enable the button
  }
};

Now, we just need to add an input to accept the cast text and a button to publish the cast. Let's add it below the hello fid text like this:

 {farcasterUser?.status == "approved" && (
        <div className={styles.castSection}>
          <div className={styles.userInfo}>Hello {farcasterUser.fid} 👋</div>
          <div className={styles.castContainer}>
            <textarea
              className={styles.castTextarea}
              placeholder="What's on your mind?"
              value={text}
              onChange={(e) => setText(e.target.value)}
              rows={5}
            />

            <button
              className={styles.btn}
              onClick={handleCast}
              disabled={isCasting}
            >
              {isCasting ? <span>🔄</span> : "Cast"}
            </button>
          </div>
        </div>
      )}

Your final page.tsx file should look similar to this:

"use client";

import axios from "axios";
import QRCode from "qrcode.react";
import { useEffect, useState } from "react";
import styles from "./page.module.css";

interface FarcasterUser {
  signer_uuid: string;
  public_key: string;
  status: string;
  signer_approval_url?: string;
  fid?: number;
}

export default function Home() {
  const LOCAL_STORAGE_KEYS = {
    FARCASTER_USER: "farcasterUser",
  };
  const [loading, setLoading] = useState(false);
  const [farcasterUser, setFarcasterUser] = useState<FarcasterUser | null>(
    null
  );
  const [text, setText] = useState<string>("");
  const [isCasting, setIsCasting] = useState<boolean>(false);

  const handleCast = async () => {
    setIsCasting(true);
    const castText = text.length === 0 ? "gm" : text;
    try {
      const response = await axios.post("/api/cast", {
        text: castText,
        signer_uuid: farcasterUser?.signer_uuid,
      });
      if (response.status === 200) {
        setText(""); // Clear the text field
        alert("Cast successful");
      }
    } catch (error) {
      console.error("Could not send the cast", error);
    } finally {
      setIsCasting(false); // Re-enable the button
    }
  };

  useEffect(() => {
    const storedData = localStorage.getItem(LOCAL_STORAGE_KEYS.FARCASTER_USER);
    if (storedData) {
      const user: FarcasterUser = JSON.parse(storedData);
      setFarcasterUser(user);
    }
  }, []);

  useEffect(() => {
    if (farcasterUser && farcasterUser.status === "pending_approval") {
      let intervalId: NodeJS.Timeout;

      const startPolling = () => {
        intervalId = setInterval(async () => {
          try {
            const response = await axios.get(
              `/api/signer?signer_uuid=${farcasterUser?.signer_uuid}`
            );
            const user = response.data as FarcasterUser;

            if (user?.status === "approved") {
              // store the user in local storage
              localStorage.setItem(
                LOCAL_STORAGE_KEYS.FARCASTER_USER,
                JSON.stringify(user)
              );

              setFarcasterUser(user);
              clearInterval(intervalId);
            }
          } catch (error) {
            console.error("Error during polling", error);
          }
        }, 2000);
      };

      const stopPolling = () => {
        clearInterval(intervalId);
      };

      const handleVisibilityChange = () => {
        if (document.hidden) {
          stopPolling();
        } else {
          startPolling();
        }
      };

      document.addEventListener("visibilitychange", handleVisibilityChange);

      // Start the polling when the effect runs.
      startPolling();

      // Cleanup function to remove the event listener and clear interval.
      return () => {
        document.removeEventListener(
          "visibilitychange",
          handleVisibilityChange
        );
        clearInterval(intervalId);
      };
    }
  }, [farcasterUser]);

  const handleSignIn = async () => {
    setLoading(true);
    await createAndStoreSigner();
    setLoading(false);
  };

  const createAndStoreSigner = async () => {
    try {
      const response = await axios.post("/api/signer");
      if (response.status === 200) {
        localStorage.setItem(
          LOCAL_STORAGE_KEYS.FARCASTER_USER,
          JSON.stringify(response.data)
        );
        setFarcasterUser(response.data);
      }
    } catch (error) {
      console.error("API Call failed", error);
    }
  };

  return (
    <div className={styles.container}>
      {!farcasterUser?.status && (
        <button
          className={styles.btn}
          onClick={handleSignIn}
          disabled={loading}
        >
          {loading ? "Loading..." : "Sign in with farcaster"}
        </button>
      )}

      {farcasterUser?.status == "pending_approval" &&
        farcasterUser?.signer_approval_url && (
          <div className={styles.qrContainer}>
            <QRCode value={farcasterUser.signer_approval_url} />
            <div className={styles.or}>OR</div>
            <a
              href={farcasterUser.signer_approval_url}
              target="_blank"
              rel="noopener noreferrer"
              className={styles.link}
            >
              Click here to view the signer URL (on mobile)
            </a>
          </div>
        )}

      {farcasterUser?.status == "approved" && (
        <div className={styles.castSection}>
          <div className={styles.userInfo}>Hello {farcasterUser.fid} 👋</div>
          <div className={styles.castContainer}>
            <textarea
              className={styles.castTextarea}
              placeholder="What's on your mind?"
              value={text}
              onChange={(e) => setText(e.target.value)}
              rows={5}
            />

            <button
              className={styles.btn}
              onClick={handleCast}
              disabled={isCasting}
            >
              {isCasting ? <span>🔄</span> : "Cast"}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

If you now try going through the whole flow of signing in and creating a cast, everything should work seamlessly!

📘

Read more here to see how to sponsor a signer on behalf of the user

Conclusion

This guide taught us how to integrate neynar managed signers into your next.js app to add sign-in with Farcaster! If you want to look at the completed code, check out the GitHub repository.

Lastly, please share what you built with us on Farcaster by tagging @neynar and if you have any questions, reach out to us on warpcast or Telegram!