Authentication is one of those things that looks simple from the outside and turns into a multi-week project the moment you start building it properly. Sign-in, sign-up, email verification, password reset, OAuth, route protection — each piece is straightforward on its own, but wiring them all together correctly takes time and care.
Plainform ships with all of it already done. This post walks through exactly how it works, from the moment a user lands on the sign-up page to the moment they are authenticated and using your app.
The Foundation: Clerk
Plainform uses Clerk as its authentication provider. Clerk is a hosted identity platform that handles sessions, tokens, OAuth flows, and user management. The reason it is the right choice here is not just convenience — it is that authentication is a security-critical system, and delegating it to a service that specializes in it is the pragmatic call.
All the auth logic in Plainform is built on top of Clerk's React hooks and server-side helpers. The forms are custom, the UI is Plainform's own, but the underlying session management and identity verification is Clerk's.
One thing that makes Clerk particularly well-suited for Next.js projects is how naturally it fits into the App Router model. Server components, client components, middleware, and API routes all have first-class access to the current user's session through consistent, well-documented APIs. There is no context juggling or custom session providers to set up. You call auth() on the server or useAuth() on the client and you get what you need.
Sign-Up Flow
When a new user signs up, they go through two steps: creating their account and verifying their email address.
Step 1: Account creation
The SignUpForm component handles the initial form. It collects an email address and password, validates them client-side with Zod before anything hits the network, and then calls Clerk's signUp.create method.
The password rules are enforced via a shared Zod schema:
export const signUpSchema = z.object({
email_address: z.string().email().toLowerCase().trim(),
password: z
.string()
.min(8)
.regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
.regex(/[0-9]/, { message: 'Contain at least one number.' })
.regex(/[^a-zA-Z0-9]/, { message: 'Contain at least one special character.' }),
});Once Clerk accepts the account creation, it immediately triggers email verification:
await signUp.create({ emailAddress: email_address, password });
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' });
setVerifying(true);The component then swaps itself out for the verification form without any page navigation.
Step 2: Email verification
The SignUpVerificationForm renders a six-digit OTP input. The user enters the code from their email, and the form calls signUp.attemptEmailAddressVerification. If the code is correct and the sign-up is complete, Clerk returns a session ID and the user is activated:
const signUpAttempt = await signUp.attemptEmailAddressVerification({ code });
if (signUpAttempt.status === 'complete') {
await setActive({ session: signUpAttempt.createdSessionId });
if (signUpAttempt.emailAddress) {
await addContact(signUpAttempt.emailAddress); // adds to newsletter list
}
router.push('/');
}One detail worth noting: after a successful sign-up, the user's email is automatically added to the Resend contact list. This is the newsletter subscription hook, and it happens silently as part of the auth flow.
The verification form also includes a resend timer. Users have to wait 60 seconds before requesting a new code, which is enforced client-side with a countdown and a disabled resend button.
This two-step flow is worth the extra friction. Verifying the email address up front means you are not accumulating unverified accounts in your user list, and it confirms that the person signing up actually controls the email they provided. It also gives Clerk enough signal to establish a trusted identity before issuing a session.
Sign-In Flow
Sign-in is more straightforward. The SignInForm collects an email and password, validates with the same Zod schema pattern, and calls signIn.create:
const signInAttempt = await signIn.create({ identifier, password });
if (signInAttempt.status === 'complete') {
await setActive({ session: signInAttempt.createdSessionId });
router.push('/');
window.location.reload();
}The window.location.reload() after the redirect is intentional. It ensures that any server components that depend on the session state are re-rendered with the fresh session rather than serving stale cached output.
The form also supports being rendered inside a modal via an isInModal prop. When in modal mode, it skips the router redirect and just reloads the page in place.
OAuth: Google and GitHub
Both sign-in and sign-up support OAuth via Google and GitHub. The OAuthConnection component handles both cases with a single abstraction:
const signInWith = (strategy: OAuthStrategy) => {
return signIn?.authenticateWithRedirect({
strategy,
redirectUrl: '/sso-callback',
redirectUrlComplete: '/',
});
};
const signUpWith = (strategy: OAuthStrategy) => {
return signUp?.authenticateWithRedirect({
strategy,
redirectUrl: '/sso-callback',
redirectUrlComplete: '/',
});
};The component takes an isSignedUp prop to decide which method to call. Both flows redirect through /sso-callback, which is a dedicated page that handles the OAuth handshake completion before sending the user to the home page.
Adding a new OAuth provider is a matter of adding a new OAuthConnection instance with the right strategy string and enabling the provider in the Clerk dashboard.
From the user's perspective, OAuth is the fastest path to getting into the app. No password to remember, no verification email to wait for. They click the Google or GitHub button, authorize the app, and they are in. Clerk handles the entire OAuth handshake, token exchange, and session creation behind the scenes.
Password Reset Flow
The forgot password flow is split across two components: ForgotPasswordForm and ResetPasswordForm.
The first form collects the user's email and calls:
await signIn?.create({
strategy: 'reset_password_email_code',
identifier,
});
setSuccessfulCreation(true);This triggers Clerk to send a reset code to the email address. The component then swaps to ResetPasswordForm, which collects the code and the new password together. The reset password schema adds a confirmation field with a cross-field validation:
export const resetPasswordSchema = z
.object({
code: z.string().min(6).max(6),
password: z.string().min(8).regex(/[a-zA-Z]/).regex(/[0-9]/).regex(/[^a-zA-Z0-9]/),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});The .refine at the end is what enforces that both password fields match before the form can be submitted.
Middleware-Level Route Protection
All of the above handles the user-facing flows. The middleware is what enforces auth rules at the infrastructure level, before any page code runs.
Plainform uses clerkMiddleware from @clerk/nextjs/server. It runs on every request and handles two things:
Redirecting authenticated users away from auth pages
If a signed-in user tries to visit /sign-in, /sign-up, /forgot-password, or /sso-callback, they get redirected to the home page:
const isProtectedBySignInStatus = createRouteMatcher([
'/sign-in(.*)',
'/sign-up(.*)',
'/forgot-password(.*)',
'/sso-callback(.*)',
]);
export default clerkMiddleware(async (auth, req) => {
const { userId } = await auth();
if (userId && isProtectedBySignInStatus(req)) {
const url = req.nextUrl.clone();
url.pathname = '/';
return NextResponse.redirect(url);
}
});This prevents the awkward situation where a logged-in user lands on the sign-in page and sees a form they do not need.
Validating the order route
The /order route requires a valid Stripe checkout session ID in the query string. The middleware validates this before the page renders:
if (isOrderRoute(req)) {
const sessionId = req.nextUrl.searchParams.get('session_id');
if (!sessionId) {
return NextResponse.redirect('/');
}
try {
await stripe.checkout.sessions.retrieve(sessionId);
return NextResponse.next();
} catch {
return NextResponse.redirect('/');
}
}Anyone trying to access the order confirmation page without a valid session gets bounced back to the home page. This is not auth in the traditional sense, but it is the same pattern: enforce access rules at the edge before any page logic runs.
Error Handling
Every form in the auth flow uses the same error handling pattern. Clerk returns structured errors with a meta.paramName field that maps directly to form field names. The forms use React Hook Form's setError to attach those errors to the right fields:
err.errors.forEach((error: ClerkAPIError) => {
const paramName = error.meta?.paramName as keyof IFormData | undefined;
if (paramName && error.longMessage) {
setError(paramName, { type: 'manual', message: error.longMessage });
} else {
toast.error(error?.message);
}
});Field-level errors show up inline next to the relevant input. Errors that do not map to a specific field — like a network failure or a rate limit — show up as toast notifications.
How It All Fits Together
The auth system in Plainform is not a single component or a single file. It is a set of pieces that each handle one part of the problem cleanly:
- Zod schemas validate input before it touches the network
- Clerk hooks handle the actual identity operations
- Custom form components own the UI and UX
- The middleware enforces access rules at the edge
What makes this architecture solid is that each layer has a single responsibility and a clear boundary. The Zod schemas do not know about Clerk. The form components do not know about the middleware. The middleware does not know about the form components. Each piece can be understood, tested, and modified in isolation.
When you clone Plainform and add your Clerk API keys to the environment variables, all of this works immediately. The sign-up flow, the email verification, the OAuth buttons, the password reset, the middleware protection — it is all there and it is all wired together correctly.
That is the point. Authentication is not the interesting part of your product. Plainform makes sure it is never the thing slowing you down.
