SavvySolve Docs

Clerk Authentication

User authentication and session management with Clerk

Clerk Authentication

SavvySolve uses Clerk for authentication, providing a complete user management solution without building custom auth infrastructure. Clerk handles sign-up, sign-in, session management, and user profiles, allowing the team to focus on core product features.

Why Clerk?

Authentication is a security-critical component that's easy to get wrong. Rolling custom auth means handling password hashing, session management, email verification, password reset flows, social login integrations, and security updates. Clerk handles all of this out of the box.

For SavvySolve specifically, Clerk's value proposition aligns well with the target users:

  • Seniors (65+) benefit from familiar sign-in flows and social login options
  • Busy professionals appreciate quick sign-in without friction
  • Small business owners may already use Google Workspace, making Google sign-in seamless

Clerk's pricing (free up to 10,000 monthly active users) makes it cost-effective for the MVP phase while providing enterprise-grade security.

Architecture Overview

The Clerk integration has four main components:

Note: Next.js 16+ uses proxy.ts instead of middleware.ts. The functionality is identical, but the file convention has changed.

Provider Setup

The ClerkProvider wraps the entire application in app/layout.tsx, making authentication state available throughout the component tree:

app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs";

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

The provider order matters: ClerkProvider wraps everything because authentication state must be available before any other providers or components render.

Route Protection

The proxy intercepts requests before they reach route handlers, enforcing authentication where required:

proxy.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher([
  "/",
  "/sign-in(.*)",
  "/sign-up(.*)",
  "/api/webhooks(.*)",
  "/api/trpc(.*)",
  "/docs(.*)",
]);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }
});

Public routes include the landing page, authentication pages, webhooks (which use their own verification), the tRPC API (which has its own auth checks via procedures), and documentation.

Protected routes automatically redirect unauthenticated users to the sign-in page. After signing in, users return to their original destination.

tRPC Integration

The tRPC context exposes authentication state to all procedures, enabling both public and protected endpoints:

server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { auth } from "@clerk/nextjs/server";

interface AuthState {
  userId: string | null;
  sessionId: string | null;
}

export interface Context {
  auth: AuthState;
}

export async function createContext(): Promise<Context> {
  const { userId, sessionId } = await auth();
  return {
    auth: { userId, sessionId },
  };
}

Two procedure types handle different authorization needs:

server/trpc.ts
// Public procedure - anyone can call
export const publicProcedure = t.procedure;

// Protected procedure - requires authentication
const enforceAuth = t.middleware(async ({ ctx, next }) => {
  if (!ctx.auth.userId) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be signed in to access this resource",
    });
  }
  return next({ ctx });
});

export const protectedProcedure = t.procedure.use(enforceAuth);

Use publicProcedure for endpoints like health checks or public ticket submission. Use protectedProcedure for solver dashboards, session management, and anything requiring a user identity.

Webhook User Sync

When users sign up or update their profiles in Clerk, webhooks notify SavvySolve to keep the local database synchronized:

app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import type { WebhookEvent } from "@clerk/nextjs/server";
import {
  extractUserData,
  syncUserToDatabase,
  handleUserDeletion,
} from "@/lib/services/user-sync";

export async function POST(req: Request) {
  // Verify webhook signature
  const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
  const evt = wh.verify(body, headers) as WebhookEvent;

  // Handle events
  switch (evt.type) {
    case "user.created":
    case "user.updated":
      const userData = extractUserData(evt.data);
      await syncUserToDatabase(userData);
      break;
    case "user.deleted":
      await handleUserDeletion(evt.data.id);
      break;
  }
}

The user sync service extracts relevant fields from Clerk's user object and persists them locally:

lib/services/user-sync.ts
export interface ClerkUserData {
  clerkId: string;
  email: string | null;
  firstName: string | null;
  lastName: string | null;
  imageUrl: string | null;
}

export function extractUserData(clerkUser: UserJSON): ClerkUserData {
  const primaryEmail = clerkUser.email_addresses.find(
    (email) => email.id === clerkUser.primary_email_address_id
  );

  return {
    clerkId: clerkUser.id,
    email: primaryEmail?.email_address ?? null,
    firstName: clerkUser.first_name,
    lastName: clerkUser.last_name,
    imageUrl: clerkUser.image_url,
  };
}

Webhook handlers use upsert logic for idempotency—Clerk may retry webhooks, and the handler should produce the same result regardless of how many times it runs.

Environment Variables

The integration requires three environment variables:

VariableDescription
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYPublic key for client-side SDK (starts with pk_)
CLERK_SECRET_KEYSecret key for server-side operations (starts with sk_)
CLERK_WEBHOOK_SECRETWebhook signing secret for signature verification (starts with whsec_)

Get these from the Clerk Dashboard:

  • Keys are under API Keys
  • Webhook secret is created when adding a webhook endpoint under Webhooks

Setting Up Webhooks

To enable user sync:

  1. Go to Webhooks in the Clerk Dashboard
  2. Add endpoint: https://your-domain.com/api/webhooks/clerk
  3. Subscribe to events: user.created, user.updated, user.deleted
  4. Copy the signing secret to CLERK_WEBHOOK_SECRET

For local development, use a tunnel service like ngrok to expose your local server.

Using Auth in Components

Client components can access auth state through Clerk's hooks:

"use client";

import { useUser, SignInButton, UserButton } from "@clerk/nextjs";

export function Header() {
  const { isSignedIn, user } = useUser();

  return (
    <header>
      {isSignedIn ? (
        <>
          <span>Welcome, {user.firstName}</span>
          <UserButton />
        </>
      ) : (
        <SignInButton />
      )}
    </header>
  );
}

On this page