Ploy
Ploy

Authentication

Add user authentication to your Next.js application with Ploy Auth.

Authentication

This guide shows how to integrate Ploy Auth into a Next.js application using the @meetploy/auth-react package.

Setup

1. Install Dependencies

pnpm add @meetploy/auth-react

2. Configure Auth Binding

Add the auth configuration to your ploy.yaml:

ploy.yaml
kind: dynamic
build: pnpm build
out: dist
auth:
  binding: AUTH_DB

3. Add AuthProvider

Wrap your application with the AuthProvider:

app/layout.tsx
import { AuthProvider } from "@meetploy/auth-react";

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

Pre-Built Components

Both forms read the project's sign-in methods and render what is enabled: Google, Apple, and GitHub buttons, a passkey option, and the email and password fields. Turning a method on or off in the dashboard updates the forms without a redeploy.

SignInForm

A sign-in form with every enabled method:

app/login/page.tsx
"use client";

import { SignInForm } from "@meetploy/auth-react";
import { useRouter } from "next/navigation";

export default function LoginPage() {
	const router = useRouter();

	return (
		<div className="max-w-md mx-auto mt-20 p-6">
			<h1 className="text-2xl font-bold mb-6">Sign In</h1>
			<SignInForm
				onSuccess={() => router.push("/dashboard")}
				onError={(error) => console.error(error.message)}
			/>
			<p className="mt-4 text-center text-sm">
				Don't have an account?{" "}
				<a href="/signup" className="text-blue-500">
					Sign up
				</a>
			</p>
		</div>
	);
}

SignUpForm

A sign-up form with every enabled method. When passkeys are on, users can create a passkey instead of a password:

app/signup/page.tsx
"use client";

import { SignUpForm } from "@meetploy/auth-react";
import { useRouter } from "next/navigation";

export default function SignUpPage() {
	const router = useRouter();

	return (
		<div className="max-w-md mx-auto mt-20 p-6">
			<h1 className="text-2xl font-bold mb-6">Create Account</h1>
			<SignUpForm
				onSuccess={() => router.push("/dashboard")}
				onError={(error) => console.error(error.message)}
			/>
			<p className="mt-4 text-center text-sm">
				Already have an account?{" "}
				<a href="/login" className="text-blue-500">
					Sign in
				</a>
			</p>
		</div>
	);
}

Custom Metadata Fields

Add extra fields to collect user metadata during signup:

<SignUpForm
	fields={["name", "company"]}
	onSuccess={(user) => console.log(user)}
/>

Component Props

Both components accept these props:

interface SignInFormProps {
	onSuccess?: (user: PloyUser) => void; // Called after successful auth
	onError?: (error: Error) => void; // Called on error
	redirectTo?: string; // Auto-redirect URL after success
	className?: string; // Custom CSS class
}

interface SignUpFormProps extends SignInFormProps {
	fields?: string[]; // Additional metadata fields to collect
}

useAuth Hook

Access auth state anywhere in your app:

components/user-menu.tsx
"use client";

import { useAuth } from "@meetploy/auth-react";

export function UserMenu() {
	const { user, isLoading, signOut } = useAuth();

	if (isLoading) {
		return <div>Loading...</div>;
	}

	if (!user) {
		return <a href="/login">Sign In</a>;
	}

	return (
		<div className="flex items-center gap-4">
			<span>{user.email}</span>
			<button onClick={signOut}>Sign Out</button>
		</div>
	);
}

Hook Return Value

interface AuthContextValue {
	user: PloyUser | null; // Current user or null
	isLoading: boolean; // True until the session and config are loaded
	isAuthenticated: boolean; // True if a user is signed in
	config: AuthConfig | null; // Enabled sign-in methods
	redirectError: string | null; // ploy_auth_error code after a failed social sign-in

	signUp: (
		email: string,
		password: string,
		metadata?: Record<string, unknown>,
	) => Promise<PloyUser>;
	signIn: (email: string, password: string) => Promise<PloyUser>;
	requestPasswordReset: (
		email: string,
		options?: { redirectTo?: string },
	) => Promise<void>;
	resetPassword: (token: string, password: string) => Promise<void>;
	sendMagicLink: (
		email: string,
		options?: { redirectTo?: string; metadata?: Record<string, unknown> },
	) => Promise<void>;
	changePassword: (input: {
		currentPassword?: string; // required when user.hasPassword
		newPassword: string;
	}) => Promise<PloyUser>;
	resendVerification: (
		email: string,
		options?: { redirectTo?: string },
	) => Promise<void>;
	signInWithProvider: (
		provider: "google" | "apple" | "github",
		options?: { redirectTo?: string },
	) => void;
	signInWithPasskey: () => Promise<PloyUser>;
	signUpWithPasskey: (
		email: string,
		metadata?: Record<string, unknown>,
	) => Promise<PloyUser>;
	addPasskey: (name?: string) => Promise<void>;
	listPasskeys: () => Promise<PloyPasskey[]>;
	removePasskey: (id: string) => Promise<void>;
	signOut: () => Promise<void>;
}

The session lives in an HttpOnly cookie, so there is no token to store or refresh in the browser.

Failed calls reject with a PloyAuthError whose code matches the error codes below. See Emails, sign-in links and passwords for the email and password methods.

Protected Routes

Client-Side Protection

app/dashboard/page.tsx
"use client";

import { useAuth } from "@meetploy/auth-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

export default function DashboardPage() {
	const { user, isLoading } = useAuth();
	const router = useRouter();

	useEffect(() => {
		if (!isLoading && !user) {
			router.push("/login");
		}
	}, [user, isLoading, router]);

	if (isLoading) {
		return <div>Loading...</div>;
	}

	if (!user) {
		return null;
	}

	return (
		<div>
			<h1>Welcome, {user.email}</h1>
			{user.metadata?.name && <p>Name: {user.metadata.name}</p>}
		</div>
	);
}

Protected API Routes

The ploy_session cookie is sent with every same-origin request. Verify it on the server with the Next.js helpers:

app/api/protected/route.ts
import { auth } from "@meetploy/nextjs/auth";

export async function GET() {
	const { user } = await auth();
	if (!user) {
		return Response.json({ error: "Unauthorized" }, { status: 401 });
	}

	return Response.json({ message: "Protected data", userId: user.id });
}

Client components call it with a plain fetch("/api/protected"); no Authorization header is needed. In Server Components use currentUser(), or protect() to redirect signed-out visitors to sign-in.

Styling Components

The pre-built components use minimal inline styles. Override with your own CSS:

<SignInForm className="my-custom-form" />
globals.css
.my-custom-form input {
	@apply border-2 border-gray-300 rounded-lg px-4 py-2;
}

.my-custom-form button {
	@apply bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded;
}

Or build custom forms using the useAuth hook directly:

"use client";

import { useAuth } from "@meetploy/auth-react";
import { useState } from "react";

export function CustomSignInForm() {
	const { signIn } = useAuth();
	const [email, setEmail] = useState("");
	const [password, setPassword] = useState("");
	const [error, setError] = useState("");

	const handleSubmit = async (e: React.FormEvent) => {
		e.preventDefault();
		try {
			await signIn(email, password);
			// Handle success
		} catch (err) {
			setError(err instanceof Error ? err.message : "Sign in failed");
		}
	};

	return <form onSubmit={handleSubmit}>{/* Your custom UI */}</form>;
}

Error Handling

Common error responses from auth endpoints:

StatuscodeDescription
400invalid_emailEmail validation failed
400weak_passwordPassword shorter than 8 characters
401invalid_credentialsWrong email or password
401unauthorizedMissing, expired, or revoked session
400invalid_tokenReset, verification, or sign-in link is invalid, expired, or used
400name_requiredThe project requires a name for new accounts
403email_not_verifiedThe project requires a verified email; SignInForm offers to resend it
403password_disabledEmail and password sign-in is turned off
403signups_disabledSign-ups are turned off
403magic_link_disabledSign-in links are turned off
403email_unavailableAuth emails are off until email is enabled for the organization
409user_existsEmail already registered
429rate_limitedToo many attempts; see Retry-After

Handle errors in your components:

<SignInForm
	onError={(error) => {
		if (error.message === "Invalid credentials") {
			toast.error("Wrong email or password");
		} else {
			toast.error("Something went wrong");
		}
	}}
/>

Full Example

See the complete example at examples/nextjs-auth.

Next Steps

How is this guide?

Last updated on