Overview
Buildstory uses Clerk for authentication, providing a secure and flexible auth system with support for multiple sign-in methods.
Buildstory uses custom auth forms (not Clerk’s pre-built components) for a tailored user experience that matches the platform’s design.
Authentication Methods
Users can authenticate using:
Email and Password — Traditional email/password auth with email verification
Google OAuth — One-click sign-up/sign-in with Google
GitHub OAuth — One-click sign-up/sign-in with GitHub
Multi-Factor Authentication (2FA)
For users with 2FA enabled, Buildstory supports:
TOTP (Authenticator Apps) — Google Authenticator, Authy, etc.
Email codes — 6-digit codes sent to email
Phone codes — SMS verification codes
Backup codes — Fallback recovery codes
Sign-Up Flow
The sign-up process is implemented in app/(auth)/sign-up/page.tsx and consists of multiple steps:
Step 1: Choose Username
Users first claim a unique username:
// Real-time validation with debounced availability check
const usernameStatus : UsernameStatus = useMemo (() => {
const trimmed = username . trim (). toLowerCase ();
if ( ! trimmed ) return "idle" ;
if ( ! USERNAME_REGEX . test ( trimmed )) return "invalid" ;
if ( checkResult ?. username === trimmed ) return checkResult . status ;
return "checking" ;
}, [ username , checkResult ]);
Username requirements:
3-30 characters
Start and end with alphanumeric characters
Can contain : letters (a-z), numbers (0-9), hyphens (-), underscores (_)
Real-time availability check via server action
The username is stored in sessionStorage during sign-up and applied to the profile after successful authentication.
Step 2: Create Account
Users can sign up via:
Email/Password Sign-Up
// Create account with email/password
await signUp . create ({
emailAddress: email ,
password ,
});
// Send verification code
await signUp . prepareEmailAddressVerification ({
strategy: "email_code" ,
});
Validation:
Email : Must be valid format
Password : Minimum 8 characters
Verification code : 6-digit code sent to email
OAuth Sign-Up (Google/GitHub)
// OAuth sign-up with redirect
await signUp . authenticateWithRedirect ({
strategy: "oauth_google" , // or "oauth_github"
redirectUrl: "/sign-up/sso-callback" ,
redirectUrlComplete: "/hackathon" ,
});
OAuth flows:
User clicks Google/GitHub button
Redirects to provider for authentication
Returns to /sign-up/sso-callback for processing
Creates session and redirects to /hackathon onboarding
Step 3: Email Verification (Email/Password Only)
For email/password sign-ups:
const result = await signUp . attemptEmailAddressVerification ({
code , // 6-digit code from email
});
if ( result . status === "complete" ) {
await setActive ({ session: result . createdSessionId });
// Username is applied to profile after session creation
router . push ( "/hackathon" );
}
Sign-In Flow
The sign-in process is implemented in app/(auth)/sign-in/page.tsx:
Email/Password Sign-In
const result = await signIn . create ({
identifier: email ,
password ,
});
if ( result . status === "complete" ) {
await setActive ({ session: result . createdSessionId });
router . push ( "/dashboard" );
}
OAuth Sign-In
await signIn . authenticateWithRedirect ({
strategy: "oauth_google" , // or "oauth_github"
redirectUrl: "/sign-in/sso-callback" ,
redirectUrlComplete: "/dashboard" ,
});
Two-Factor Authentication Flow
If the user has 2FA enabled:
if ( result . status === "needs_second_factor" ) {
const factors = result . supportedSecondFactors ;
// Prefer TOTP > phone_code > email_code
const factor =
factors ?. find (( f ) => f . strategy === "totp" ) ??
factors ?. find (( f ) => f . strategy === "phone_code" ) ??
factors ?. find (( f ) => f . strategy === "email_code" );
// Prepare the second factor
if ( strategy === "phone_code" ) {
await signIn . prepareSecondFactor ({
strategy ,
phoneNumberId: factor . phoneNumberId ,
});
}
// Show verification screen
setNeedsSecondFactor ( true );
}
Verification:
const result = await signIn . attemptSecondFactor ({
strategy: secondFactorStrategy ,
code , // 6-digit code or backup code
});
if ( result . status === "complete" ) {
await setActive ({ session: result . createdSessionId });
router . push ( "/dashboard" );
}
Auth Layout
Auth pages use a custom two-column layout (app/(auth)/layout.tsx):
Left column : Auth form with Buildstory logo and footer
Right column : Dark panel (hidden on mobile)
< div className = "flex min-h-svh" >
< div className = "flex w-full flex-col lg:w-1/2" >
{ /* Logo, Form, Footer */ }
</ div >
< div className = "hidden border-l border-border bg-neutral-950 lg:block lg:w-1/2" />
</ div >
Middleware & Route Protection
Buildstory uses Next.js 16’s proxy convention (proxy.ts) instead of traditional middleware:
Public Routes
/ — Landing page (signed-in users redirect to /dashboard)
/sign-in, /sign-up — Auth pages
/banned — Shown to banned users
Protected Routes
Unauthenticated users are redirected to / (landing page):
const appRoutes = [ "/dashboard" , "/projects" , "/members" , "/streamers" , "/settings" , "/hackathon" , "/invite" ];
if ( ! userId && appRoutes . some (( r ) => request . nextUrl . pathname . startsWith ( r ))) {
return NextResponse . redirect ( new URL ( "/" , request . url ));
}
Admin Routes
Admin and moderator routes are protected:
if ( request . nextUrl . pathname . startsWith ( "/admin" ) || request . nextUrl . pathname . startsWith ( "/studio" )) {
if ( ! userId ) {
return NextResponse . redirect ( new URL ( "/" , request . url ));
}
// /studio requires admin, /admin allows admin + moderator
const hasAccess = request . nextUrl . pathname . startsWith ( "/studio" )
? await isAdmin ( userId )
: await canAccessAdmin ( userId );
if ( ! hasAccess ) {
return NextResponse . redirect ( new URL ( "/" , request . url ));
}
}
Banned User Handling
if ( userId && profile ?. bannedAt ) {
return NextResponse . redirect ( new URL ( "/banned" , request . url ));
}
Profile Creation (Just-in-Time)
Buildstory uses lazy profile creation—profiles are created on-demand rather than via webhooks:
// lib/db/ensure-profile.ts
export const ensureProfile = cache ( async ( clerkId : string ) => {
// Check for existing profile
const existing = await db . query . profiles . findFirst ({
where: eq ( profiles . clerkId , clerkId ),
});
if ( existing ) return existing ;
// Fetch user data from Clerk
const user = await clerkClient (). users . getUser ( clerkId );
// Create profile with race-condition safety
const [ profile ] = await db
. insert ( profiles )
. values ({
clerkId ,
displayName: user . firstName || user . username || "New User" ,
})
. onConflictDoNothing ()
. returning ();
return profile ;
});
ensureProfile is wrapped in React’s cache() for per-request deduplication—safe to call multiple times in a single request without redundant database queries.
Session Management
Clerk handles session management automatically:
Server-side : auth() from @clerk/nextjs/server
Client-side : useAuth(), useUser() hooks
Session cookie : Managed by Clerk, httpOnly, secure in production
Environment Variables
Required Clerk environment variables:
# Clerk Keys
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = pk_test_...
CLERK_SECRET_KEY = sk_test_...
# Auth Routes
NEXT_PUBLIC_CLERK_SIGN_IN_URL = /sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL = /sign-up
# Post-Auth Redirects
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL = /dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL = /hackathon
Use test keys (pk_test_*, sk_test_*) for development and preview environments. Only production uses live keys (pk_live_*, sk_live_*).
User Roles
Buildstory implements a role-based access control system:
Available Roles
User (default) — Standard member access
Moderator — Can access /admin routes for user management
Admin — Full access including /studio and role management
Role Helpers
// lib/admin.ts
// Check if user is admin
await isAdmin ( clerkUserId );
// Check if user is moderator
await isModerator ( clerkUserId );
// Check if user can access admin panel (admin OR moderator)
await canAccessAdmin ( clerkUserId );
// Get user's role
await getRole ( clerkUserId ); // Returns: "user" | "moderator" | "admin"
Super Admin
For bootstrapping, ADMIN_USER_IDS environment variable grants admin access:
ADMIN_USER_IDS = user_abc123,user_def456
// Sync check (no DB query)
isSuperAdmin ( clerkUserId ); // Checks ADMIN_USER_IDS env var
Security Features
Email Verification All email/password sign-ups require email verification before account activation.
Password Requirements Minimum 8 characters enforced with client and server-side validation.
Rate Limiting Clerk provides built-in rate limiting for auth endpoints to prevent abuse.
Session Security httpOnly, secure cookies in production. Sessions managed by Clerk.
Common Auth Patterns
Server Component
import { auth } from "@clerk/nextjs/server" ;
export default async function Page () {
const { userId } = await auth ();
if ( ! userId ) {
// Redirect or show public view
}
// Fetch user-specific data
}
Client Component
"use client" ;
import { useAuth , useUser } from "@clerk/nextjs" ;
export function Component () {
const { isLoaded , userId } = useAuth ();
const { user } = useUser ();
if ( ! isLoaded ) return < Spinner /> ;
if ( ! userId ) return < SignInPrompt /> ;
return < AuthenticatedView user = { user } /> ;
}
Protecting Server Actions
"use server" ;
import { auth } from "@clerk/nextjs/server" ;
export async function updateProfile ( data : FormData ) {
const { userId } = await auth ();
if ( ! userId ) {
return { success: false , error: "Unauthorized" };
}
// Proceed with update
}
Troubleshooting
”Redirect loop” on sign-in
Ensure NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL is set to /dashboard and the user has permissions to access it.
OAuth callback errors
Verify that Clerk dashboard has the correct redirect URLs configured:
Development: http://localhost:3000/sign-in/sso-callback
Production: https://buildstory.com/sign-in/sso-callback
Profile not created after sign-up
Profiles are created lazily. If a profile doesn’t exist, ensureProfile() will create it on the next authenticated request.
2FA code not working
Ensure:
Code is entered within the time window (typically 30 seconds for TOTP)
No extra spaces in the code input
Backup codes are used only once