
In this post we’ll get into how you can set up a new NextJS project and use Turnkey to generate passkeys in the browser, and set them up as a user’s authentication method for wallets.
From here you could build a dApp and use them as embedded wallets, provide ways for users to fund a wallet or perform swaps and other transactions, and more.
Let’s get into it.
Prerequisites
This tutorial assumes you have some familiarity with web development and NextJS in particular, as well as NodeJS/NPM installed.
Create a new NextJS Project
Create a new NextJS project with the following command:
npm create next@latest tk-passkeys-web-app
When prompted, you can accept the default options by hitting enter. Then, from inside of the directory, run:
npm install @turnkey/sdk-server @turnkey/sdk-browser @turnkey/sdk-react - legacy-peer-deps
The — legacy-peer-deps
flag ensures compatibility with different package versions for your project. Now you can open the directory up in your favorite code editor, and run npm run dev
to start a local dev server.
Initial Configuration
Create a .env
file to store all of your environment variables. We’ll start with our Turnkey credentials.
NEXT_PUBLIC_TURNKEY_RP_ID="localhost"
TURNKEY_API_PRIVATE_KEY="YOUR_API_PRIVATE_KEY_HERE"
TURNKEY_API_PUBLIC_KEY="YOUR_API_PUBLIC_KEY_HERE"
TURNKEY_ORGANIZATION_ID="YOUR_TURNKEY_ORGANIZATION_ID_HERE"
You can generate an API keypair on Turnkey by heading to your Profile -> User Details, and then clicking Create API Key. Make sure that these stay private, and aren’t committed to your GitHub repository.
We set the rpId
as localhost
here for testing purposes, but in production you would need to switch this to the domain your web app is hosted on.
Updating Layout.tsx
We can remove most of the boilerplate code from the layout.tsx
file.
The layout is essentially code that will wrap every page we create for our web application — so in this page, we will set up a Turnkey Provider to wrap the application. This makes Turnkey’s functionality available for when we generate passkeys on the client later on.
We also set up some basic metadata, like the web page’s title and description.
import type { Metadata } from "next";
import "./globals.css";
import { TurnkeyProvider } from "@turnkey/sdk-react";
export const metadata: Metadata = {
title: "Turnkey Passkey Web App",
description: "Web App with Turnkey Passkey Authentication",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`antialiased`}
>
<TurnkeyProvider
config={{
rpId: process.env.NEXT_PUBLIC_TURNKEY_RP_ID,
apiBaseUrl: process.env.NEXT_PUBLIC_TURNKEY_API_BASE_URL,
defaultOrganizationId: process.env.TURNKEY_ORGANIZATION_ID,
}}
>
{children}
</TurnkeyProvider>
</body>
</html>
);
}
Setting some CSS globals
To keep things simple, we’ll update the globals.css
file to just use the Tailwind directives, and theme our page separately.
@tailwind base;
@tailwind components;
@tailwind utilities;
Now we can create our page for users to sign up to our application with a Passkey.
Creating a Sign-Up Page
In page.tsx
remove the boilerplate code, and add the following:
'use client';
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTurnkey } from "@turnkey/sdk-react";
export default function Home() {
const router = useRouter();
const { passkeyClient } = useTurnkey();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [isLoading, setIsLoading] = useState(false);
const createSubOrg = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// Create a new passkey for the user
const credential = await passkeyClient?.createUserPasskey({
publicKey: {
rp: {
name: "Wallet Passkey",
},
user: {
name: email,
displayName: name,
},
},
});
if (!credential) {
console.error("Failed to create passkey");
setIsLoading(false);
return;
}
console.log("Passkey created successfully");
// Now create the sub-organization on the server
const response = await fetch('/api/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: "passkey",
email,
name,
challenge: credential.encodedChallenge,
attestation: credential.attestation,
}),
});
const result = await response.json();
router.push(`/wallet?organizationId=${result.organizationId}`);
} catch (error) {
console.error("Error:", error);
setIsLoading(false);
}
};
Let’s break this down step by step.
Inside of our homepage, the first thing we do is set up some state variables for the form users will submit to sign up with a passkey. This includes a username and email, as well as a loading state for our form.
We also set up a router for page switching, and initialized Turnkey’s passkey helper. This will let us generate a Passkey for a user when they press a button below, and redirect them to a new page if they sign up successfully.
We then create a function called `createSubOrg`
, which does exactly what it says — when the form is submitted and the button is pressed, it will prompt the user to generate a Passkey and send detials of the credential to the backend of our application (which we’ll set up shortly).
Inside of the return
statement after this, we set up our HTML for the component which will actually display a form on the screen.
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-white">
<div className="w-full max-w-md bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-800 text-center mb-6">Create Your Wallet</h1>
<form onSubmit={createSubOrg} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900"
placeholder="Your username"
disabled={isLoading}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900"
placeholder="[email protected]"
disabled={isLoading}
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{isLoading ? "Creating..." : "Create Wallet with Passkey"}
</button>
</form>
</div>
</div>
);
We take in a username and an email, and when the Create Wallet with Passkey
button is pressed, the form will submit and the sign up process will take place.
In production, you would likely need to add error-handling and validation for this process, which is omitted for the sake for simplicity.
Setting up a Server-Side API Route
So we have a page where users can submit a sign-up form with a passkey — now we need something to actually happen when this button is pressed.
For this, we’ll set up a new API route which creates a sub-organization with a user’s credentials, and generates an Ethereum wallet for them.
Create a new path within your app
directory such as api/auth/route.ts
.
Inside of it, add the following code:
import { NextResponse } from "next/server";
import { Turnkey, DEFAULT_ETHEREUM_ACCOUNTS } from "@turnkey/sdk-server";
const turnkeyServer = new Turnkey({
apiBaseUrl: process.env.NEXT_PUBLIC_TURNKEY_API_BASE_URL || "https://api.turnkey.com",
apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY!,
apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY!,
defaultOrganizationId: process.env.TURNKEY_ORGANIZATION_ID!,
}).apiClient();
export async function POST(request: Request) {
try {
const body = await request.json();
if (body.type !== "passkey") {
return NextResponse.json(
{ message: "Unsupported authentication type" },
{ status: 400 }
);
}
const { email, name, challenge, attestation, rpId } = body;
const response = await turnkeyServer.createSubOrganization({
subOrganizationName: `${name}'s Wallet`,
rootUsers: [
{
userName: name,
userEmail: email,
apiKeys: [],
authenticators: [
{
authenticatorName: "Default Passkey",
challenge: challenge,
attestation: attestation,
},
],
oauthProviders: [],
},
],
rootQuorumThreshold: 1,
wallet: {
walletName: "Default Wallet",
accounts: DEFAULT_ETHEREUM_ACCOUNTS,
},
});
return NextResponse.json({
message: "Sub-organization created successfully",
organizationId: response.subOrganizationId,
});
} catch (error: any) {
console.error("Error creating sub-organization:", error);
return NextResponse.json(
{ message: error.message || "Failed to create sub-organization" },
{ status: 500 }
);
}
}
The first thing we do is set up a Turnkey client using our API keys. This is an API route which executes server-side, as we don’t want to expose our API keys publicly on the frontend.
Using our Turnkey client, we simply accept the request containing the passkey credentials, and call createSubOrganization
to create a sub-organization for the user, using their passkey as the auth method.
The rootQuorumThreshold
is 1, meaning they are the only user who can access the sub-organization, and we pass in the default Ethereum curve settings to generate a wallet.
We then return the sub-organization ID in the response, so we can easily display it to the user.
Creating a wallet page
The last thing to do is create a wallet page.
In our main page.tsx
we set up a redirect once a sub-organization is created, to redirect to a wallet page. Now, we need to actually create a wallet page for this to happen.
Create a new path wallet/page.tsx
in your app
directory, and add the following code:
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
export default function Wallet() {
const router = useRouter();
const searchParams = useSearchParams();
const organizationId = searchParams.get('organizationId');
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-white">
<div className="w-full max-w-md bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-800 text-center mb-6">Your Wallet</h1>
<div className="space-y-4">
<div className="border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-500">Organization ID</p>
<p className="font-mono text-sm break-all">{organizationId}</p>
</div>
<p className="text-center text-gray-600">
Your Turnkey sub-organization has been created successfully!
</p>
<button
onClick={() => router.push('/')}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md font-medium hover:bg-blue-700"
>
Back to Home
</button>
</div>
</div>
</div>
We use useSearchParams
to get the sub-organization ID passed in the URL so we can display it on the page, and useRouter
to create a button send users back to the home page.
What’s next?
Congratulations, you just set up a non-custodial, passkey-powered app using Turnkey!
From here you can test it out yourself locally, add integrations to let users fund their wallets, display their balance, and set up granular auth control so users can only access data from their own sub-organization ID.
Check out our docs to see what you can build, and join our Slack for more technical tips.