Clerk Satellite Domain Implementation Guide

Overview

This guide provides detailed implementation steps for setting up Clerk satellite domains in the do.dev monorepo architecture.

Satellite Domain Configuration

1. Environment Configuration

Primary Domain (auth.do.dev)

# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
CLERK_SECRET_KEY=sk_test_***

# Domain configuration
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_DOMAIN=localhost:3000

# Allowed satellite domains
CLERK_SATELLITE_URLS=http://localhost:3005,http://localhost:3001,...

Satellite Domain (do.dev)

# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
CLERK_SECRET_KEY=sk_test_***

# Satellite configuration
NEXT_PUBLIC_CLERK_IS_SATELLITE=true
NEXT_PUBLIC_CLERK_DOMAIN=localhost:3000
NEXT_PUBLIC_CLERK_SIGN_IN_URL=http://localhost:3000/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=http://localhost:3000/sign-up
CLERK_SATELLITE_URL=http://localhost:3005

2. Provider Setup

// apps/webs/dodev/components/app-providers.tsx
"use client"

import { ClerkProvider } from '@clerk/nextjs'
import { ConvexProvider, ConvexReactClient } from "convex/react"
import { ReactNode, useMemo } from 'react'

export function AppProviders({ children }: { children: ReactNode }) {
  const convex = useMemo(() => {
    const url = process.env.NEXT_PUBLIC_CONVEX_URL
    if (!url) {
      console.warn("NEXT_PUBLIC_CONVEX_URL is not set")
      return new ConvexReactClient("https://dummy.convex.cloud")
    }
    return new ConvexReactClient(url)
  }, [])

  return (
    <ClerkProvider
      publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      isSatellite={true}
      domain={process.env.NEXT_PUBLIC_CLERK_DOMAIN}
      signInUrl={process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL}
      signUpUrl={process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL}
    >
      <ConvexProvider client={convex}>
        {children}
      </ConvexProvider>
    </ClerkProvider>
  )
}

3. Middleware Configuration

// apps/webs/dodev/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

const isPublicRoute = createRouteMatcher([
  '/',
  '/login',
  '/signup',
  '/products',
  '/contact',
  '/api/webhooks(.*)',
  '/_next(.*)',
])

const isProtectedRoute = createRouteMatcher([
  '/dashboard(.*)',
  '/profile(.*)',
  '/organization(.*)',
  '/admin(.*)',
])

export default clerkMiddleware(async (auth, request) => {
  try {
    if (isPublicRoute(request)) {
      return NextResponse.next()
    }

    const { userId } = await auth()
    
    if (isProtectedRoute(request) && !userId) {
      const primaryAuthUrl = process.env.NEXT_PUBLIC_PRIMARY_AUTH_URL || 'http://localhost:3000'
      const authUrl = new URL('/sign-in', primaryAuthUrl)
      authUrl.searchParams.set('return', request.url)
      return NextResponse.redirect(authUrl)
    }

    return NextResponse.next()
  } catch (error) {
    console.error('Middleware error:', error)
    return NextResponse.next()
  }
})

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
}

Authentication Hook Implementation

1. useAuth Hook

// apps/webs/dodev/hooks/useClerkAuth.ts
"use client"

import { useUser, useAuth as useClerkAuth } from '@clerk/nextjs'
import { useRouter } from "next/navigation"
import { useCallback } from "react"

interface AuthUser {
  id?: string
  _id?: string
  email?: string | null
  name?: string | null
  givenName?: string | null
  familyName?: string | null
  picture?: string | null
  image?: string | null
  roles?: string[]
  appId?: string
  custId?: string
  userId?: string
  [key: string]: any
}

interface UseAuthReturn {
  user: AuthUser | null
  isAuthenticated: boolean
  isLoading: boolean
  login: (options?: any) => Promise<void>
  logout: () => Promise<void>
  register: (options?: any) => Promise<void>
  getToken: () => Promise<string | undefined>
  hasRole: (role: string) => boolean
  hasAnyRole: (roles: string[]) => boolean
  hasAllRoles: (roles: string[]) => boolean
  isAdmin: boolean
  isStaff: boolean
}

export function useAuth(): UseAuthReturn {
  const { isSignedIn, isLoaded } = useClerkAuth()
  const { user: clerkUser } = useUser()
  const router = useRouter()

  const user: AuthUser | null = clerkUser ? {
    id: clerkUser.id,
    _id: clerkUser.id,
    email: clerkUser.emailAddresses[0]?.emailAddress || null,
    name: clerkUser.fullName,
    givenName: clerkUser.firstName,
    familyName: clerkUser.lastName,
    picture: clerkUser.imageUrl,
    image: clerkUser.imageUrl,
    roles: (clerkUser.publicMetadata?.roles as string[]) || [],
    appId: clerkUser.publicMetadata?.appId as string,
    custId: clerkUser.publicMetadata?.custId as string,
    userId: clerkUser.publicMetadata?.userId as string,
    ...clerkUser.publicMetadata,
  } : null

  const login = useCallback(async () => {
    if (typeof window !== 'undefined') {
      const returnUrl = encodeURIComponent(window.location.origin)
      const authUrl = `${process.env.NEXT_PUBLIC_PRIMARY_AUTH_URL || 'http://localhost:3000'}/sign-in?return=${returnUrl}`
      window.location.href = authUrl
    }
  }, [])

  const logout = useCallback(async () => {
    if (typeof window !== 'undefined') {
      const authUrl = `${process.env.NEXT_PUBLIC_PRIMARY_AUTH_URL || 'http://localhost:3000'}/sign-out?return=${encodeURIComponent(window.location.origin)}`
      window.location.href = authUrl
    }
  }, [])

  const register = useCallback(async () => {
    if (typeof window !== 'undefined') {
      const returnUrl = encodeURIComponent(window.location.origin)
      const authUrl = `${process.env.NEXT_PUBLIC_PRIMARY_AUTH_URL || 'http://localhost:3000'}/sign-up?return=${returnUrl}`
      window.location.href = authUrl
    }
  }, [])

  const getToken = useCallback(async () => {
    return undefined // Clerk handles tokens internally
  }, [])

  const hasRole = (role: string): boolean => {
    if (!user?.roles) return false
    return user.roles.includes(role)
  }

  const hasAnyRole = (roles: string[]): boolean => {
    if (!user?.roles) return false
    return roles.some((role) => user.roles!.includes(role))
  }

  const hasAllRoles = (roles: string[]): boolean => {
    if (!user?.roles) return false
    return roles.every((role) => user.roles!.includes(role))
  }

  const isAdmin = hasRole("do-root") || hasRole("root") || hasRole("admin") || hasRole("super_admin")
  const isStaff = hasRole("do-staff") || hasRole("staff") || hasRole("root") || hasRole("super_admin") || isAdmin

  return {
    user,
    isAuthenticated: !!isSignedIn,
    isLoading: !isLoaded,
    login,
    logout,
    register,
    getToken,
    hasRole,
    hasAnyRole,
    hasAllRoles,
    isAdmin,
    isStaff,
  }
}

API Routes

1. Sign-Out API (Primary Domain)

// apps/webs/auth/app/api/sign-out/route.ts
import { auth, clerkClient } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  try {
    const returnUrl = request.nextUrl.searchParams.get('return') || 'http://localhost:3005'
    const authResult = await auth()
    
    if (authResult?.sessionId) {
      const client = await clerkClient()
      try {
        await client.sessions.revokeSession(authResult.sessionId)
      } catch (revokeError) {
        console.error('Session revoke error:', revokeError)
      }
    }
    
    const decodedReturnUrl = decodeURIComponent(returnUrl)
    const response = NextResponse.redirect(decodedReturnUrl)
    
    // Clear all Clerk-related cookies
    const cookiesToClear = [
      '__session',
      '__session_hint', 
      '__clerk_db_jwt',
      '__client',
      '__client_uat',
      'clerk-db-jwt',
      'clerk-session'
    ]
    
    cookiesToClear.forEach(cookieName => {
      response.cookies.set(cookieName, '', {
        maxAge: 0,
        path: '/',
        domain: '.do.dev'
      })
      response.cookies.set(cookieName, '', {
        maxAge: 0,
        path: '/'
      })
    })
    
    return response
  } catch (error) {
    console.error('Sign out error:', error)
    const returnUrl = request.nextUrl.searchParams.get('return') || 'http://localhost:3005'
    const decodedReturnUrl = decodeURIComponent(returnUrl)
    return NextResponse.redirect(decodedReturnUrl)
  }
}

2. Profile Update API (Satellite Domain)

// apps/webs/dodev/app/api/profile/route.ts
import { auth, clerkClient } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  try {
    const { userId } = await auth()
    
    if (!userId) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }

    const body = await request.json()
    const { displayName, bio } = body

    const client = await clerkClient()
    
    const updateData: any = {}
    
    if (displayName !== undefined) {
      updateData.firstName = displayName.split(' ')[0] || ''
      updateData.lastName = displayName.split(' ').slice(1).join(' ') || ''
    }
    
    if (bio !== undefined) {
      updateData.publicMetadata = {
        ...((await client.users.getUser(userId)).publicMetadata || {}),
        bio
      }
    }

    const updatedUser = await client.users.updateUser(userId, updateData)

    return NextResponse.json({ success: true, user: updatedUser })
  } catch (error) {
    console.error('Profile update error:', error)
    return NextResponse.json(
      { error: 'Failed to update profile' },
      { status: 500 }
    )
  }
}

Common Issues and Solutions

1. Infinite Redirect Loop

Problem: Browser shows "too many redirects" error

Solution:

# Add to satellite domain .env.local
CLERK_SATELLITE_URL=http://localhost:3005
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=http://localhost:3005
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=http://localhost:3005

2. Headers Warning in Next.js 15

Problem: "Route used ...headers() or similar iteration"

Solution:

// Make middleware async and await auth()
export default clerkMiddleware(async (auth, request) => {
  const { userId } = await auth() // Note the await
  // ... rest of middleware
})

3. Session Not Syncing

Problem: User authenticated on primary but not on satellite

Solution:

  • Ensure both domains use the same Clerk instance
  • Check CORS settings allow credentials
  • Verify satellite configuration in ClerkProvider

4. Server Component Serialization Error

Problem: "Only plain objects can be passed to Client Components"

Solution:

  • Convert auth pages to Client Components
  • Use API routes for server-side operations
  • Pass only serializable data to client components

Testing Checklist

  • User can sign in from satellite domain
  • Redirect to auth domain preserves return URL
  • After auth, user returns to original domain
  • Sign out works from any domain
  • Session persists across domains
  • Protected routes redirect properly
  • Profile updates work correctly
  • Roles are properly synchronized
  • No infinite redirect loops
  • No console errors or warnings

Production Considerations

1. Domain Configuration

// Production environment variables
NEXT_PUBLIC_CLERK_DOMAIN=auth.do.dev
NEXT_PUBLIC_CLERK_SIGN_IN_URL=https://auth.do.dev/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=https://auth.do.dev/sign-up

2. Security Headers

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'X-Frame-Options',
            value: 'SAMEORIGIN'
          },
          {
            key: 'Content-Security-Policy',
            value: "frame-ancestors 'self' *.do.dev"
          }
        ]
      }
    ]
  }
}

3. Performance Optimization

  • Enable Clerk session caching
  • Implement user data prefetching
  • Use edge middleware for auth checks
  • Minimize cross-domain redirects

On this page