Skip to main content

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:
  1. User clicks Google/GitHub button
  2. Redirects to provider for authentication
  3. Returns to /sign-up/sso-callback for processing
  4. 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
For more help, check the Clerk documentation or ask in the Buildstory Discord.

Build docs developers (and LLMs) love