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:30052. 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:30052. 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-up2. 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