Clerk Satellite Domain Authentication Setup Guide

Complete implementation guide for setting up Clerk satellite domain authentication with smooth UX

Overview

This guide documents the complete process for setting up satellite domain authentication using Clerk, where multiple domains authenticate through a single primary domain. Based on the successful implementation of local.dev (localhost:3010) as a satellite of do.dev (localhost:3005).

Architecture

Primary Domain (do.dev)
├── Handles all authentication
├── Manages user sessions
├── Validates satellite redirects
└── Redirects back to satellite after auth

Satellite Domain (local.dev, doc.dev, etc.)
├── Redirects to primary for authentication
├── Receives authenticated users back
├── Shares session via Clerk's satellite mechanism
└── Provides smooth UX during transitions

Prerequisites

  • Clerk account with satellite domain support
  • Same Clerk keys across all domains
  • Proper CORS and redirect configuration

Step-by-Step Implementation

1. Primary Domain Configuration (do.dev)

A. Environment Variables (.env.local)

# Standard Clerk configuration
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

# Allow satellite redirects
CLERK_ALLOWED_REDIRECT_ORIGINS="http://localhost:3010,http://localhost:3016,https://local.dev,https://doc.dev"

B. ClerkProvider Setup (app/layout.tsx or app-providers.tsx)

<ClerkProvider
  publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!}
  allowedRedirectOrigins={[
    // Development
    "http://localhost:3010", // local.dev
    "http://localhost:3016", // doc.dev
    // Add other satellite development ports

    // Production
    "https://local.dev",
    "https://doc.dev",
    // Add other production satellite domains
  ]}
>
  {children}
</ClerkProvider>

C. Satellite Handler Component (app/sign-in/_components/SatelliteHandler.tsx)

"use client"

import { useEffect } from "react"
import { useSearchParams } from "next/navigation"

// List of allowed satellite domains
const SATELLITE_DOMAINS = {
  development: [
    "localhost:3010", // local.dev dev
    "localhost:3016", // doc.dev dev
    "localhost:3026", // Additional dev
    "localhost:3011", // send.dev dev
    "localhost:3012", // talk.dev dev
    // Add new satellite development ports here
  ],
  production: [
    "local.dev",
    "doc.dev",
    "send.dev",
    "talk.dev",
    // Add new satellite production domains here
  ],
}

/**
 * Handles cross-domain authentication for satellite domains
 * This component detects if the sign-in was initiated from a satellite domain
 * and ensures proper redirect after successful authentication
 */
export function SatelliteHandler() {
  const searchParams = useSearchParams()

  useEffect(() => {
    // Check if this is a satellite authentication request
    const redirectOrigin = searchParams.get("redirect_origin")
    const returnPath = searchParams.get("return_path")

    if (redirectOrigin) {
      // Validate that the origin is allowed
      const isAllowedOrigin = [
        ...SATELLITE_DOMAINS.development,
        ...SATELLITE_DOMAINS.production,
      ].some(domain => {
        if (redirectOrigin.includes(domain)) {
          return true
        }
        return false
      })

      if (isAllowedOrigin) {
        // Store satellite redirect info in sessionStorage
        // This will be used after successful authentication
        sessionStorage.setItem("satellite_redirect", JSON.stringify({
          origin: redirectOrigin,
          path: returnPath || "/",
          timestamp: Date.now(),
        }))
      }
    }
  }, [searchParams])

  return null // This component doesn't render anything
}

/**
 * Handle satellite redirect after successful authentication
 * This should be called in the welcome page or after sign-in success
 */
export function handleSatelliteRedirect() {
  if (typeof window === "undefined") return

  const satelliteRedirectStr = sessionStorage.getItem("satellite_redirect")

  if (satelliteRedirectStr) {
    try {
      const satelliteRedirect = JSON.parse(satelliteRedirectStr)

      // Check if redirect is still valid (within 10 minutes)
      const isValid = Date.now() - satelliteRedirect.timestamp < 600000

      if (isValid && satelliteRedirect.origin) {
        // Clear the stored redirect
        sessionStorage.removeItem("satellite_redirect")

        // Build the redirect URL
        const protocol = satelliteRedirect.origin.includes("localhost") ? "http://" : "https://"
        const redirectUrl = `${protocol}${satelliteRedirect.origin}${satelliteRedirect.path}`

        // Redirect to satellite domain
        // Clerk's satellite cookies will handle the authentication
        window.location.href = redirectUrl
      }
    } catch (error) {
      console.error("[SatelliteHandler] Error handling redirect:", error)
      sessionStorage.removeItem("satellite_redirect")
    }
  }
}

D. Include SatelliteHandler in Sign-In Page

import { SatelliteHandler } from "./_components/SatelliteHandler"

export default function SignInPage() {
  return (
    <>
      <SatelliteHandler />
      {/* Your sign-in UI */}
    </>
  )
}

E. Handle Post-Authentication Redirect

In your welcome page or after successful sign-in:

import { handleSatelliteRedirect } from "../sign-in/_components/SatelliteHandler"

export default function WelcomePage() {
  useEffect(() => {
    // Handle satellite redirect after successful authentication
    handleSatelliteRedirect()
  }, [])

  // Rest of your component
}

2. Satellite Domain Configuration

A. Environment Variables (.env.local)

# Same Clerk keys as primary domain
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

# CRITICAL: Mark this as a satellite domain (REQUIRED)
NEXT_PUBLIC_CLERK_IS_SATELLITE=true
NEXT_PUBLIC_CLERK_DOMAIN=localhost:3010  # Change to your satellite domain

# Authentication happens at primary domain
NEXT_PUBLIC_CLERK_SIGN_IN_URL=http://localhost:3005/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=http://localhost:3005/sign-up

# Redirect URLs - where to go AFTER authentication (back to satellite domain)
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=http://localhost:3010/app
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=http://localhost:3010/app

# App URL for this satellite
NEXT_PUBLIC_APP_URL=http://localhost:3010

# Development
NODE_ENV=development

# Production versions (adjust domains accordingly):
# NEXT_PUBLIC_CLERK_DOMAIN=your-satellite.dev
# NEXT_PUBLIC_CLERK_SIGN_IN_URL=https://do.dev/sign-in
# NEXT_PUBLIC_CLERK_SIGN_UP_URL=https://do.dev/sign-up
# NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=https://your-satellite.dev/app
# NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=https://your-satellite.dev/app
# NEXT_PUBLIC_APP_URL=https://your-satellite.dev

B. ClerkProvider Setup (app/layout.tsx)

import { ClerkProvider } from "@clerk/nextjs"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const isDevelopment = process.env.NODE_ENV === "development"

  const clerkConfig = {
    // SATELLITE DOMAIN CONFIGURATION (following official Clerk documentation)
    // CRITICAL: domain should be the SATELLITE'S OWN domain, not the primary domain
    isSatellite: true,
    domain: isDevelopment ? "localhost:3010" : "your-satellite.dev", // Satellite's own domain
    signInUrl: isDevelopment ? "http://localhost:3005/sign-in" : "https://do.dev/sign-in",
    signUpUrl: isDevelopment ? "http://localhost:3005/sign-up" : "https://do.dev/sign-up",
  }

  return (
    <ClerkProvider {...clerkConfig}>
      <html lang="en">
        <body>
          {children}
        </body>
      </html>
    </ClerkProvider>
  )
}

C. Sign-In Page with Smooth UX (app/sign-in/[[...sign-in]]/page.tsx)

"use client"

import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@clerk/nextjs"

// This page handles sign-in for satellite domains
// Redirects to primary domain for authentication
export default function SignInPage() {
  const router = useRouter()
  const { isSignedIn, isLoaded } = useAuth()

  useEffect(() => {
    if (!isLoaded) return

    // If user is already signed in, redirect to app
    if (isSignedIn) {
      router.replace("/app")
      return
    }

    // Small delay to show loading state before redirect
    const timer = setTimeout(() => {
      // For satellite domains, redirect to primary domain for authentication
      const primaryDomain =
        process.env.NODE_ENV === "development"
          ? "http://localhost:3005"
          : "https://do.dev"

      // Current satellite domain
      const satelliteDomain =
        process.env.NODE_ENV === "development"
          ? "localhost:3010"  // Change to your satellite domain
          : "your-satellite.dev"  // Change to your satellite domain

      // Build redirect URL with satellite parameters for proper redirect back
      const redirectParams = new URLSearchParams({
        redirect_origin: satelliteDomain,
        return_path: "/app", // Align with NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL
      })

      const signInUrl = `${primaryDomain}/sign-in?${redirectParams.toString()}`
      router.replace(signInUrl)
    }, 800) // 800ms delay for smooth UX

    return () => clearTimeout(timer)
  }, [router, isSignedIn, isLoaded])

  // Show loading spinner instead of blank page
  if (!isLoaded) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-green-50 via-white to-gray-50">
        <div className="text-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
          <p className="mt-4 text-gray-600">Loading authentication...</p>
        </div>
      </div>
    )
  }

  // Show redirect message while redirecting to primary domain
  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-green-50 via-white to-gray-50">
      <div className="text-center">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
        <p className="mt-4 text-gray-600">Redirecting to sign in...</p>
        <p className="text-sm text-gray-500 mt-2">You'll be back here in a moment</p>
      </div>
    </div>
  )
}

Quick Setup Checklist for New Satellite Domains

For Each New Satellite Domain:

  1. Choose Port/Domain:

    • Development: localhost:[PORT] (e.g., localhost:3017)
    • Production: your-domain.dev
  2. Update Primary Domain (do.dev):

    • Add to SATELLITE_DOMAINS in SatelliteHandler.tsx
    • Add to allowedRedirectOrigins in ClerkProvider
    • Add to CLERK_ALLOWED_REDIRECT_ORIGINS env var
  3. Configure Satellite Domain:

    • Set NEXT_PUBLIC_CLERK_IS_SATELLITE=true
    • Set NEXT_PUBLIC_CLERK_DOMAIN to satellite's own domain
    • Set sign-in/sign-up URLs to primary domain
    • Set after-auth URLs back to satellite domain
    • Configure ClerkProvider with isSatellite: true
  4. Copy Sign-In Page:

    • Copy the sign-in page component with loading states
    • Update satellite domain references in the component
  5. Test Authentication Flow:

    • Verify redirect to primary domain
    • Verify redirect back to satellite after auth
    • Test loading states and smooth transitions

Port Assignment Convention

Satellite DomainDevelopment PortProduction Domain
local.devlocalhost:3010local.dev
doc.devlocalhost:3016doc.dev
send.devlocalhost:3011send.dev
talk.devlocalhost:3012talk.dev
Next Satellitelocalhost:3017your-domain.dev

Common Issues and Troubleshooting

Issue: Infinite Redirect Loop

Error: "Clerk: Refreshing the session token resulted in an infinite redirect loop" Solution: Ensure NEXT_PUBLIC_CLERK_IS_SATELLITE=true is set in satellite domain

Issue: Stays on Primary Domain After Auth

Cause: Missing or incorrect redirect parameters Solution: Verify redirect_origin and return_path parameters are sent correctly

Issue: Rough UX with Abrupt Redirects

Solution: Implement loading spinners and delays as shown in the sign-in page example

Issue: 403 Forbidden on Redirect

Cause: Satellite domain not in allowed redirect origins Solution: Add domain to CLERK_ALLOWED_REDIRECT_ORIGINS and ClerkProvider config

Testing Checklist

  • Sign-in redirects to primary domain with loading state
  • Primary domain shows satellite handler working
  • After authentication, redirects back to satellite domain
  • User session is properly shared across domains
  • Loading states provide smooth UX
  • No infinite redirect loops
  • Works in incognito/private browsing mode
  • Both development and production configurations work

Files Modified/Created for Each Implementation

Primary Domain (do.dev):

  1. app/sign-in/_components/SatelliteHandler.tsx - Add satellite domain
  2. app/layout.tsx or app-providers.tsx - Add to allowedRedirectOrigins
  3. .env.local - Add to CLERK_ALLOWED_REDIRECT_ORIGINS

Satellite Domain:

  1. .env.local - Complete satellite configuration
  2. app/layout.tsx - ClerkProvider satellite config
  3. app/sign-in/[[...sign-in]]/page.tsx - Sign-in page with loading states

Next Steps

When implementing additional satellite domains:

  1. Follow the Quick Setup Checklist above
  2. Use the next available port (currently 3017)
  3. Test thoroughly using the Testing Checklist
  4. Update this documentation with any new learnings

Last Updated: January 2025 Tested With: Clerk satellite authentication, Next.js 15, local.dev → do.dev Status: ✅ Production Ready

On this page