DEV Community

Onwuka David
Onwuka David

Posted on

Passkey Login & Smart Wallet Creation on Solana with Next.js and LazorKit — No More Seed Phrases!

Time to complete: 15-20 minutes

A significant percentage of crypto users have permanently lost access to their wallets due to forgotten seed phrases. But what if seed phrases weren’t required at all?

In this tutorial, you’ll learn how to build a passwordless Solana wallet using LazorKit’s passkey integration using biometric authentication like Face ID and Touch ID. The result is Web2-level UX with Web3-grade security—no extensions, no seed phrases.

By the end, you’ll understand how passkeys work and have a fully functional smart wallet connection flow running on Solana.

Who this tutorial is for

  • Developers familiar with React / Next.js

  • Anyone curious about replacing seed phrases with passkeys

  • Builders exploring gasless Solana UX with LazorKit

What are Passkeys?

Passkeys are a modern authentication standard (WebAuthn) that replaces passwords and seed phrases with biometric authentication.

Forget long seed phrases and confusing backups. With passkeys, users access their wallets using FaceID, TouchID, or Windows Hello, dramatically improving the user experience of crypto applications while maintaining strong security guarantees.

Before we dive in properly, let's look at how passkey wallets compare to traditional wallets:

Authentication

  • Traditional Wallet: 12–24 word seed phrase

  • Passkey Wallet: A quick tap with your fingerprint or face

Storage

  • Traditional Wallet: Seed phrase must be written down and stored securely

  • Passkey Wallet: Cryptographic keys are stored in the device’s Secure Enclave

Security

  • Traditional Wallet: Seed phrases can be lost, stolen, or copied

  • Passkey Wallet: Access is bound to a cryptographic key protected by device-level biometrics or system authentication.

Device Sync

  • Traditional Wallet: Same seed phrase must be manually imported on every device

  • Passkey Wallet: Securely synced across devices via iCloud or Google

Setup Time

  • Traditional Wallet: 5+ minutes of setup and memorization

  • Passkey Wallet: Ready in under 30 seconds

In short: Passkeys make wallet onboarding fast, secure, and intuitive — bringing Web2-level UX to Web3 applications without compromising on security.

Having covered why passkeys offer a better alternative to traditional wallets, we’ll now move on to the implementation and integrate passkey authentication step by step.

Prerequisites

Before starting this tutorial, ensure you have:

Having met these prerequisites, we can now safely begin integration.

Step 1: Setup the Provider

First, ensure your root layout has the LazorkitProvider:

// app/providers.tsx
"use client";
import React, { useEffect } from "react";
import { LazorkitProvider } from "@lazorkit/wallet";
import { Buffer } from "buffer";
import { Toaster } from "react-hot-toast";

const LAZORKIT_CONFIG = {
  rpcUrl: "https://api.devnet.solana.com",
  portalUrl: "https://portal.lazor.sh",
  paymasterConfig: {
    paymasterUrl: "https://kora.devnet.lazorkit.com",
  },
};

export function AppProviders({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    // Buffer polyfill for browser
    if (typeof window !== "undefined" && !window.Buffer) {
      window.Buffer = Buffer;
    }
  }, []);

  return (
    <LazorkitProvider
      rpcUrl={LAZORKIT_CONFIG.rpcUrl}
      portalUrl={LAZORKIT_CONFIG.portalUrl}
      paymasterConfig={LAZORKIT_CONFIG.paymasterConfig}
    >
      {children}
      <Toaster position="top-right" />
    </LazorkitProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Listing 1-1: Setting up the LazorkitProvider with configuration

This code sets up the foundation for passkey authentication. Let's break it down line by line:

The "use client" directive at the top tells Next.js this is a client component. This is necessary because LazorKit uses browser APIs like WebAuthn that don't exist on the server. Next are the imports. One is of particular concern here:

import { LazorkitProvider } from "@lazorkit/wallet";
Enter fullscreen mode Exit fullscreen mode

We import LazorkitProvider, which is a React context provider that makes wallet functionality available throughout your app. Any component that needs wallet access must be wrapped by this provider.

const LAZORKIT_CONFIG = {
  rpcUrl: "https://api.devnet.solana.com",
  portalUrl: "https://portal.lazor.sh",
  paymasterConfig: {
    paymasterUrl: "https://kora.devnet.lazorkit.com",
  },
};
Enter fullscreen mode Exit fullscreen mode

The configuration object contains three essential URLs:

  • rpcUrl: The Solana RPC endpoint for blockchain communication (we use Devnet for testing in this case)
  • portalUrl: LazorKit's authentication portal where passkey ceremonies happen
  • paymasterUrl: The service that sponsors gas fees for gasless transactions.

Moving on to the next line, we have:

useEffect(() => {
  if (typeof window !== "undefined" && !window.Buffer) {
    window.Buffer = Buffer;
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

This useEffect hook adds a Buffer polyfill to the browser's window object. Solana's web3.js library expects Node.js's Buffer class, which browsers don't have natively. In other words, we need to add this buffer class. We then check for window first to avoid errors during server-side rendering. Moving on, we need to have our app with the AppProviders as Listing 1-2 illustrates.

Wrap Your App

In your layout.tsx, do this:

// app/layout.tsx
import { AppProviders } from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <AppProviders>{children}</AppProviders>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Listing 1-2: Wrapping your application with AppProviders

This is the root layout that wraps your entire Next.js application. The key line is:

<AppProviders>{children}</AppProviders>
Enter fullscreen mode Exit fullscreen mode

By wrapping {children} with AppProviders, every page and component in your app gains access to the wallet context via the useWallet hook. Without this wrapper, calling useWallet() would throw an error.

You can find more information about this setup on the lazorkit's docs

Step 2: Create the Login Page

Now we create a login page for wallet connection:

// app/(auth)/login/page.tsx
"use client";
import { useWallet } from "@lazorkit/wallet";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";

export default function LoginPage() {
  const router = useRouter();
  const {
    connect, // Function to initiate connection
    isConnected, // Boolean: is wallet connected?
    isConnecting, // Boolean: is connection in progress?
    wallet, // Wallet info (smartWallet address)
  } = useWallet();

  const [error, setError] = useState<string | null>(null);

  // Redirect to dashboard if already connected
  useEffect(() => {
    if (isConnected && wallet?.smartWallet) {
      router.push("/transfer");   // transfer is a page I decided to use here. Be at liberty to use any of your choosing
    }
  }, [isConnected, wallet, router]);

  // We'll implement this next...
  const handleConnect = async () => {
    /* ... */
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-[#0a0a0a]">
      <div className="max-w-md w-full p-8">
        <h1 className="text-3xl font-bold text-white text-center mb-8">
          Welcome to PassPay
        </h1>

        {/* Connection button will go here */}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Listing 1-3: Basic login page structure with useWallet hook

This code creates the foundation for our login page. Let's examine the key parts:

const { connect, isConnected, isConnecting, wallet } = useWallet();
Enter fullscreen mode Exit fullscreen mode

The useWallet hook is the primary interface to LazorKit. We destructure four essential properties:

  • connect: An async function that triggers the passkey authentication flow
  • isConnected: A boolean that tells us if a wallet session exists
  • isConnecting: A boolean that's true during the authentication process
  • wallet: An object containing the connected wallet's smartWallet address

Let's observe the next line, shall we?

useEffect(() => {
  if (isConnected && wallet?.smartWallet) {
    router.push("/transfer");
  }
}, [isConnected, wallet, router]);
Enter fullscreen mode Exit fullscreen mode

This effect runs whenever connection state changes. If the user is already connected (perhaps from a previous session stored in the browser), we automatically redirect them to the main app. The optional chaining (wallet?.smartWallet) safely handles cases where wallet might be null.

The useWallet Hook Returns

Property Type Description
connect function Initiates passkey authentication
disconnect function Clears the wallet session
isConnected boolean Whether a wallet is connected
wallet { smartWallet: string } Wallet address info
smartWalletPubkey `PublicKey \ null`
isConnecting boolean Loading state during connection
signAndSendTransaction function Signs and broadcasts transactions

Step 3: Implement Connect Function

Now, it is time to add the connection logic:

const handleConnect = async () => {
  setError(null);

  try {
    // Connect with paymaster mode for gasless transactions
    const info = await connect({ feeMode: "paymaster" });

    if (info?.credentialId) {
      // Optionally store credential for later use
      console.log("Credential ID:", info.credentialId);
    }

    toast.success("Wallet connected! 🎉");
    router.push("/transfer");
  } catch (e: unknown) {
    const err = e as Error;
    const msg = err?.message || "Connection failed";
    setError(msg);

    // User-friendly error messages
    if (msg.includes("NotAllowedError")) {
      toast.error("You cancelled the passkey prompt.");
    } else if (msg.includes("PublicKeyCredential")) {
      toast.error("Your browser does not support passkeys.");
    } else {
      toast.error("Login failed. Please try again.");
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Listing 1-4: The handleConnect function that initiates passkey authentication

This function handles the entire connection flow. Let's walk through it:

const info = await connect({ feeMode: "paymaster" });
Enter fullscreen mode Exit fullscreen mode

The connect function opens LazorKit's portal in the browser, triggering the WebAuthn ceremony. The feeMode: "paymaster" option tells LazorKit that future transactions should be gasless, meaning the paymaster will sponsor fees. This returns a WalletInfo object containing the new wallet's details.

if (info?.credentialId) {
  console.log("Credential ID:", info.credentialId);
}
Enter fullscreen mode Exit fullscreen mode

The credentialId is a unique identifier for this passkey. You might store this for analytics or to identify returning users. The same passkey always produces the same wallet address.

if (msg.includes("NotAllowedError")) {
  toast.error("You cancelled the passkey prompt.");
}
Enter fullscreen mode Exit fullscreen mode

Error handling is crucial for good UX. NotAllowedError means the user dismissed the biometric prompt—we show a friendly message rather than a cryptic error code.

Understanding connect() Options

There is a minor detail we should know about the feeMode:

await connect({
  feeMode: "paymaster", // Gasless transactions (recommended)
  // feeMode: "self",    // User pays gas fees
});
Enter fullscreen mode Exit fullscreen mode
Fee Mode Description
paymaster LazorKit sponsors transaction fees
self User pays fees from their SOL balance

So, you choose... depending on what you want your app to do.

Step 4: Display Wallet Information

Build the complete login UI:

// app/(auth)/login/page.tsx
"use client";
import { useWallet } from "@lazorkit/wallet";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";

export default function LoginPage() {
  const router = useRouter();
  const { connect, isConnected, isConnecting, wallet } = useWallet();
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (isConnected && wallet?.smartWallet) {
      router.push("/transfer");
    }
  }, [isConnected, wallet, router]);

  const handleConnect = async () => {
    setError(null);
    try {
      await connect({ feeMode: "paymaster" });
      toast.success("Wallet connected! 🎉");
    } catch (e: unknown) {
      const err = e as Error;
      const msg = err?.message || "Connection failed";
      setError(msg);

      if (msg.includes("NotAllowedError")) {
        toast.error("You cancelled the passkey prompt.");
      } else if (msg.includes("PublicKeyCredential")) {
        toast.error("Your browser does not support passkeys.");
      } else {
        toast.error("Login failed. Please try again.");
      }
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-[#0a0a0a] p-4">
      <div className="max-w-md w-full">
        {/* Header */}
        <div className="text-center mb-8">
          <h1 className="text-4xl font-bold text-white mb-2">🔐 PassPay</h1>
          <p className="text-gray-400">
            No seed phrases. Just your biometrics.
          </p>
        </div>

        {/* Card */}
        <div className="bg-[#1a1a1a] rounded-2xl p-8 border border-gray-800">
          {/* Benefits */}
          <div className="space-y-3 mb-6">
            <div className="flex items-center gap-3 text-gray-300">
              <span className="text-[#14F195]"></span>
              <span>No passwords or seed phrases</span>
            </div>
            <div className="flex items-center gap-3 text-gray-300">
              <span className="text-[#14F195]"></span>
              <span>Hardware-level security</span>
            </div>
            <div className="flex items-center gap-3 text-gray-300">
              <span className="text-[#14F195]"></span>
              <span>Syncs across your devices</span>
            </div>
          </div>

          {/* Connect Button */}
          <button
            onClick={handleConnect}
            disabled={isConnecting}
            className="w-full py-4 px-6 bg-[#9945FF] hover:bg-[#8035E0] 
                       disabled:opacity-50 disabled:cursor-not-allowed
                       text-white font-semibold rounded-xl transition-colors"
          >
            {isConnecting ? (
              <span className="flex items-center justify-center gap-2">
                <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
                  <circle
                    className="opacity-25"
                    cx="12"
                    cy="12"
                    r="10"
                    stroke="currentColor"
                    strokeWidth="4"
                    fill="none"
                  />
                  <path
                    className="opacity-75"
                    fill="currentColor"
                    d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
                  />
                </svg>
                Connecting...
              </span>
            ) : (
              "✨ Continue with Passkey"
            )}
          </button>

          {/* Footer */}
          <p className="text-xs text-gray-500 text-center mt-4">
            Powered by LazorKit  Your device is your wallet
          </p>

          {/* Error Display */}
          {error && (
            <p className="mt-4 text-sm text-red-400 text-center">{error}</p>
          )}

          {/* Success State */}
          {wallet?.smartWallet && (
            <div className="mt-4 p-4 rounded-lg bg-[#14F195]/10 border border-[#14F195]/20">
              <p className="text-sm text-[#14F195] font-semibold">
                 Wallet Created!
              </p>
              <p className="text-xs text-gray-400 mt-1 font-mono break-all">
                {wallet.smartWallet}
              </p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

How It Works Under the Hood

The WebAuthn Flow

User clicks "Connect"

  • Your app calls connect({ feeMode: "paymaster" })

LazorKit Portal opens

  • Browser triggers WebAuthn ceremony
  • User sees biometric prompt and proceeds with it

Passkey created/retrieved

  • Credential stored in Secure Enclave
  • Syncs via platform (iCloud/Google)

Smart wallet derived

  • PDA derived from credential
  • Same passkey = same wallet address

Connection complete

  • wallet.smartWallet contains address
  • Ready for transactions

This flow demonstrates how LazorKit bridges Web2 authentication and Web3 ownership—allowing developers to ship secure, non-custodial wallets without exposing users to seed phrases or browser extensions.

⚠️ Note: While passkeys remove the need for seed phrases, users should understand device recovery and platform sync implications (iCloud / Google Password Manager).

Next Steps

Explore More LazorKit examples...

This tutorial covered passkey wallet authentication—the foundation of LazorKit integration. But there's much more you can build. If you’d like to go deeper, I’ve put together PassPay, a reference repository that demonstrates LazorKit integrations across both Web (Next.js) and Mobile (React Native) with real, production-style examples. Some of these examples are:

  • Gasless Transactions - Send SOL without paying fees
  • On-Chain Memos - Write permanent blockchain messages
  • Native SOL Staking - Multi-instruction transactions with passkeys
  • Subscription Payments - Recurring payment flows
  • Session Management - Keep users logged in across sessions

📚 View the complete documentation: PassPay on GitHub includes 11 step-by-step tutorials for both Web (Next.js) and Mobile (React Native) with full working code.

Top comments (0)