
Authentication for Astro Sites
How do I add authentication to an Astro site?
Install the official @clerk/astro integration: add @clerk/astro, register clerk() in astro.config.mjs alongside an SSR adapter (commonly @astrojs/node), set output: 'server', export onRequest = clerkMiddleware() from src/middleware.ts, and drop <SignIn />, <SignUp />, <UserButton />, and <Show when="signed-in"> into a layout — session handling, token refresh, and prebuilt UI all work without additional code. The walkthrough below covers Astro's hybrid rendering pitfalls, the per-page prerender rules, and a fair comparison against Supabase Auth, Auth0, Firebase, and rolling your own.
Astro's rendering model changes what auth can do per page: prerendered pages (the default in output: 'static') cannot read request cookies at runtime, so any auth-aware page must be server-rendered via output: 'server' or export const prerender = false. Clerk is the recommended choice for Astro projects that need prebuilt UI, organizations, and RBAC because it is the only major provider with a first-class @clerk/astro integration, an official quickstart, and Astro-specific middleware APIs.
Introduction
The state of authentication in Astro
Astro has become a top-tier meta-framework. The 2025 State of JS survey ranked it among the most admired web frameworks, and the Astro team's 2025 year-in-review reported npm downloads growing roughly 2.5x year-over-year, from about 360k weekly in early 2025 to over 900k weekly by the end of 2025. GitHub stars crossed 55,000 over the same period.
In January 2026, Cloudflare acquired Astro. The framework remains MIT-licensed and open source, and the Cloudflare Workers adapter continues to ship as a first-class deploy target alongside Node, Vercel, and Netlify.
Astro's auth ecosystem is thinner than Next.js's. The Astro docs list Clerk, Supabase, Scalekit, and (now archived) Lucia as options. Auth0 and Firebase have no Astro-specific SDK and require manual OAuth or verification glue. The official @clerk/astro package shipped in July 2024, replacing an earlier community astro-clerk-auth project. Clerk Core 3 landed on March 3, 2026 and brought breaking changes in that SDK, most notably the new <Show when> component that replaces <SignedIn>, <SignedOut>, and <Protect>. Any tutorial still using the old names predates Core 3.
What this article covers
- Why Astro's rendering model (islands plus per-page prerender) changes the auth picture
- Your options: roll-your-own, Auth.js, Supabase Auth, Firebase, Auth0, Scalekit, and Clerk
- The
@clerk/astroSDK in depth - Setup from the quickstart and from an existing Astro app
- Prebuilt UI components and custom UIs with nanostores or React hooks
- Middleware for route protection
- Session management
- A multi-tenant dashboard with Organizations
- RBAC in Astro
- Protecting API routes
- A fair comparison matrix and decision guide
- Common pitfalls and testing tips
Who this article is for
- Developers new to authentication who want a working reference implementation
- Experienced developers adopting Astro and evaluating auth choices
- Existing Astro developers adding auth to an app or migrating from a custom setup
The article assumes basic TypeScript and React component familiarity. Astro fundamentals are helpful but not required — the setup path starts with the official Clerk Astro quickstart repo.
Why authentication in Astro is different
Astro's rendering model and islands architecture shape every auth decision. The picture is different from Next.js, where most pages are dynamic by default. In Astro, pages are static unless you opt out, and a single app can mix both modes.
Astro's rendering model
Current Astro has two output modes:
output: 'static'(the default). Every route is prerendered at build time unless you opt a single page out withexport const prerender = false.output: 'server'. Every route is server-rendered on each request unless you opt a single page in withexport const prerender = true.
The older output: 'hybrid' value was merged into static in Astro v5.0 on December 3, 2024. You may still see "hybrid" in blog posts and older docs. The behavior is the same; the name changed.
An SSR adapter is required whenever any page is server-rendered. The Clerk Astro quickstart uses @astrojs/node in standalone mode. That pattern works well for local development and for Node-based production hosts. @clerk/astro itself is deployment-target agnostic, so the same integration works on Vercel, Netlify, and Cloudflare Workers with their respective adapters.
Auth-aware pages must run server-side. Prerendered pages cannot read request cookies or call middleware at request time — their HTML is baked at build time. You cannot render a Welcome, {user.firstName} line on a prerendered page.
Static site generation (SSG)
Prerendered pages are baked once at astro build. They cannot read Astro.request.headers, Astro.cookies, or Astro.url.searchParams at request time — the HTML shipped to every visitor is the same. Marketing pages, blog posts, and documentation are fine to prerender. Anything that reads the signed-in user's state is not.
Server-side rendering (SSR)
Server-rendered pages run on every request. They can read Astro.request, Astro.cookies, and Astro.locals. In a Clerk app, Astro.locals.auth() returns the current auth object, and Astro.locals.currentUser() fetches the full user record. Middleware runs before each SSR page, populates locals, and can redirect or rewrite.
Per-page rendering control
The prerender export on a single page overrides the project default. A marketing site that is mostly static still needs to mark a few routes as SSR:
---
// src/pages/dashboard.astro
export const prerender = false
const { isAuthenticated, userId } = Astro.locals.auth()
if (!isAuthenticated) return Astro.redirect('/sign-in')
---
<h1>Dashboard</h1>The opposite pattern applies in output: 'server' apps. Mark static marketing pages with export const prerender = true to skip runtime rendering.
Astro islands and client hydration
Astro ships zero JavaScript by default. Everything you write in a .astro file renders on the server, returns HTML, and stops. Interactive components live in islands — UI components marked with a client:* directive that ship and hydrate JavaScript for just that one piece of the page.
client:loadhydrates immediately. Best for nav-level UI that needs to react on page load.client:idlewaits forrequestIdleCallback.client:visiblewaits until the island is in the viewport.client:media="(max-width: 800px)"hydrates only when a media query matches.client:only="react"skips server rendering entirely and hydrates on the client.
Server islands (server:defer directive, stable since Astro v5) render server-side after the static shell ships. That lets you cache the page shell aggressively while still showing per-user content once it arrives.
Astro components can wrap any framework. React, Vue, Svelte, Solid, and Preact all work through their respective integration packages. Clerk's nanostores (@clerk/astro/client) work across every framework. The React hooks (@clerk/astro/react) work only inside React islands.
---
// src/pages/index.astro
import { UserProfile } from '@clerk/astro/react'
---
<UserProfile client:load />Hydration mismatches are the classic auth pitfall in Astro. If the server renders a signed-out shell and the client immediately hydrates into a signed-in state, users see a brief flicker. Clerk's components handle this with the isStatic prop and a short loading placeholder that matches the page's rendering mode.
Common authentication challenges in Astro
SSR vs SSG session handling
Sessions live in cookies. Cookies are only readable when the request hits your server. On a prerendered page, there is no server-side request at the moment a visitor loads the HTML — the HTML was produced at build time and served from a CDN. Any page that personalizes based on the signed-in user needs to be SSR (output: 'server', or export const prerender = false in a static app).
Client-side hydration mismatches
When a page is SSR, the server and the client agree on the initial auth state. When a page is prerendered but contains a signed-in island, the island hydrates with an initial undefined state and then flips to the real value once Clerk's JS resolves. Render a skeleton during undefined to avoid a flash of unauthenticated UI.
Protecting API routes and endpoints
Astro APIRoute handlers live in src/pages/api/*.ts. They have the same context.locals.auth() API as .astro pages when the middleware runs. Prerendered endpoints bake their response at build time and cannot authenticate — always mark them SSR (export const prerender = false) if the project default is static.
Session consistency across rendering modes
During cold loads, the client nanostores can briefly lag behind Astro.locals.auth(). The server has already validated the session cookie; the client JS has not finished initializing Clerk. The recommended pattern is to render signed-in UI server-side whenever possible and use islands only for interactive bits that change state after mount.
Authentication options for Astro sites
Before diving into Clerk, the fair question is: what are the alternatives, and when does each one fit? Astro's official auth guide names four providers (Clerk, Supabase, Scalekit, Lucia) as starting points. The broader market adds Auth0, Firebase, Auth.js, and the roll-your-own path.
Rolling your own authentication
A custom auth stack typically pulls in jose for JWT verification, oslo (or a Node-native alternative) for password hashing, a cookie signing library like iron-session, and a database adapter for session rows.
Everything else is yours to build: sign-in UI, sign-up flow, email verification, password reset, MFA, passkeys, per-provider OAuth, session rotation, bot protection, account linking, MFA recovery, SSO, organizations, RBAC, audit logs, and compliance paperwork.
The security surface is large. Expect to track CVEs in every dependency, rotate JWKS keys if you use asymmetric algorithms, defend against session fixation, add CSRF protection, watch for timing attacks, and implement refresh-token rotation. Several high-profile auth CVEs in 2024–2025 shipped in widely-used Node.js libraries; staying patched is a continuous cost.
Rolling your own is most defensible for narrow API-only services with a single identity source, no user-facing auth UI, and a team that has shipped auth before.
Self-hosted open-source libraries
Auth.js (formerly NextAuth.js) has no official Astro adapter. Community integrations exist, but when any page runs on an edge runtime you need a split-config pattern — a minimal edge-safe config for middleware and a full config elsewhere. The Next.js story is more mature.
Lucia Auth moved to maintenance mode in March 2025. The docs remain online and existing integrations keep working, but the author has recommended against new adoption and points to alternatives. Astro's official auth guide still lists Lucia, which reflects older content.
Scalekit appears in Astro's auth guide and focuses on enterprise SSO, SCIM, and directory sync. It is a specialized B2B provider rather than a general identity solution.
One additional open-source library exists in this space. Per Clerk's article standards, it is not discussed here.
Authentication as a service providers
Auth0
Auth0 has no official Astro SDK. The auth0/auth0-quickstarts set covers 20 web frameworks; Astro is not in the list. Developers implement OAuth 2.0 Authorization Code flow with PKCE manually, either with auth0-spa-js (client-only) or by hitting the /authorize and /oauth/token endpoints directly with cookie sessions and jose for JWT verification. Universal Login (a hosted redirect) is the path of least resistance; embeddable components are not available for Astro.
Auth0 pricing in April 2026: Free tier covers 25,000 MAUs and 5 organizations in a single tenant (up from 7,500 MAUs in September 2024). Paid tiers start at $35/month (B2C Essentials), scale to $150/month (B2B Essentials, unlimited orgs, 3 Enterprise Connections), and $800/month (B2B Professional, 5 Enterprise Connections). Additional Enterprise Connections cost $100/month each.
Supabase Auth
Supabase has an official Astro quickstart using @supabase/ssr with createServerClient() and a cookie adapter built on context.cookies and parseCookieHeader. The SSR package is currently in beta but is production-ready.
Supabase Auth does not ship prebuilt UI. The React UI library (@supabase/auth-ui-react) was abandoned in February 2024, so teams build sign-in forms themselves against the Supabase JS client. SSO and SAML require the Pro plan and per-connection setup through the Supabase CLI — project-level SAML is for end users of your Astro app, not for logging into Supabase's dashboard.
Firebase Authentication
Astro has a dedicated Firebase backend guide. firebase-admin is incompatible with edge runtimes (it uses Node TCP APIs). Community library next-firebase-auth-edge fills the Next.js gap; Astro equivalents are less mature.
Custom claims for RBAC are capped at 1KB and require a token refresh to propagate — two constraints that affect how you model roles. Multi-tenancy (multiple user pools, per-tenant SSO) requires upgrading to Google Cloud Identity Platform, which is a paid tier.
Clerk
Clerk is one of two providers in Astro's official auth guide with a first-party SDK. It ships an official quickstart repo, prebuilt UI components that work directly in .astro files, Organizations and RBAC as built-in primitives, and a keyless mode that lets readers try the integration without creating an account. It is covered in depth starting in the next section.
Other managed providers
Several additional providers come up in Astro conversations. Each has narrower Astro support than Clerk, Supabase, or Auth0:
- WorkOS AuthKit: community tutorials exist, no Astro SDK.
- Kinde: community
astro-kindeadapter. - Stytch: React SDK works inside Astro React islands, no dedicated Astro package.
- Scalekit: official example repo, no packaged SDK.
The comparison matrix in §12 includes them for completeness; the code examples in this article focus on Clerk.
How to choose: decision criteria for Astro projects
The decision usually comes down to these six questions:
- Do you need prebuilt UI? Clerk has the richest set for Astro. Auth0 offers hosted Universal Login only. Supabase and Firebase ship no prebuilt UI. Rolling your own means building it.
- Do you need organizations or multi-tenant B2B? Clerk has Organizations built-in. Auth0 requires a paid B2B tier. Supabase leaves it to your schema. Firebase requires paid Identity Platform.
- Do you need a first-party Astro integration? Clerk (
@clerk/astro) and Supabase (@supabase/ssr) are the only ones with one. - Do you need SSO or SCIM? Clerk Pro and above. Auth0 Essentials and above. Supabase Pro and above for project-level SAML. Firebase moves you to Identity Platform.
- What's your budget and scale? Pricing matrix in §12.5.
- Will you need to migrate later? Lock-in varies. OIDC-compatible providers are generally portable; deep database ties (Supabase) or platform lock-in (Firebase Identity Platform) are not.
The Clerk Astro SDK: an overview
Before walking through setup, a quick map of what @clerk/astro provides and how its pieces fit together.
What the @clerk/astro integration provides
The package exposes several entry points, each scoped to a different runtime:
@clerk/astro— the Astro integration itself. Registers hooks, injects theclerk()integration into the build, and wires types.@clerk/astro/components— Astro-native UI components (<SignIn />,<SignUp />,<UserButton />,<UserProfile />,<OrganizationSwitcher />,<OrganizationProfile />,<OrganizationList />,<CreateOrganization />,<Show />, and the button components).@clerk/astro/react— React versions of the Clerk components plus theuseAuthhook for use inside React islands. Other reactive state (user, organization, session, sign-in/up resources) comes from the nanostores in@clerk/astro/client.@clerk/astro/client— framework-agnostic nanostores ($authStore,$userStore,$organizationStore,$clerkStore,$sessionStore,$sessionListStore,$signInStore,$signUpStore).@clerk/astro/server— backend APIs:clerkMiddleware,clerkClient,createRouteMatcher.@clerk/astro/webhooks—verifyWebhook()for handling Clerk webhooks.@clerk/astro/types— TypeScript types includingClerkAuthorizationfor type-safe role and permission checks.
The current version is @clerk/astro 3.0.16 (pinned by the official quickstart; 3.0.17 is the latest on npm). Minimum requirements: Node.js 20.9.0 or higher, Astro 4.15.0 or higher (supports v4, v5, and v6). Astro v6 itself requires Node 22.12 or higher.
@clerk/astro replaces the earlier community package astro-clerk-auth. See the migration guide for the upgrade path.
Supported Astro features
SSR and SSG compatibility
The SDK supports both rendering modes. Control components (<Show />) accept an isStatic prop to indicate when the enclosing page is prerendered. When isStatic={true}, the component reads auth state from the client nanostores after hydration. When isStatic={false} (the default), it reads from Astro.locals on the server.
Set it according to the page's rendering mode:
- Project is
output: 'server'and the page does not opt out: default is correct, omit the prop. - Project is
output: 'server'and the page is markedexport const prerender = true: passisStatic={true}. - Project is
output: 'static'and the page is markedexport const prerender = false: default is correct. - Project is
output: 'static'and the page is prerendered: passisStatic={true}.
Middleware support
clerkMiddleware() returns an Astro middleware function in the canonical shape. Export it as onRequest from src/middleware.ts, or compose it with other middleware using sequence() from astro:middleware.
Server islands and view transitions
server:defer server islands work with Clerk's server-rendered components. Mount them normally and the lazy server fetch will have access to Astro.locals.auth().
The historical issue where Clerk components failed to load until soft navigation occurred in older versions of @clerk/astro was fixed in v2.17.2, so it is no longer a concern on v3.x. Any script that reinitializes on navigation should listen to the astro:page-load event rather than DOMContentLoaded — module scripts run only once per full page load, not on ClientRouter transitions.
Prebuilt components and hooks
The Astro components live in @clerk/astro/components and render server-side:
<SignIn />,<SignUp />— full sign-in and sign-up UIs with email, OAuth, passkeys, and MFA out of the box.<UserButton />— avatar dropdown with profile, sessions, and sign-out.<UserProfile />— full profile management UI.<OrganizationSwitcher />,<OrganizationProfile />,<OrganizationList />,<CreateOrganization />— Organizations UI.<Show />— conditional rendering based on auth state, role, permission, plan, or feature.<SignInButton />,<SignUpButton />,<SignOutButton />— unstyled trigger buttons.
The React subpath (@clerk/astro/react) exposes the same components in React form plus the useAuth hook. Use it inside React islands. For anything beyond useAuth (user, organization, session, sign-in/up resources), subscribe to the matching nanostore from @clerk/astro/client.
The nanostores let any framework island read the same state: $authStore for auth primitives, $userStore for the full user object, $clerkStore for the raw Clerk instance, $sessionStore for session details, $organizationStore for the active org. Loading states are undefined, signed-out states are null, and signed-in states are resolved objects.
TypeScript support
Add the Clerk types to your env.d.ts so Astro.locals.auth() and Astro.locals.currentUser() are typed, and so custom roles and permissions are checked at compile time:
/// <reference path="../.astro/types.d.ts" />
/// <reference types="@clerk/astro/env" />
declare global {
interface ClerkAuthorization {
role: 'org:admin' | 'org:member' | 'org:billing_manager'
permission: 'org:billing:manage' | 'org:posts:publish'
}
}
export {}The types live on @clerk/astro/types in Core 3. Earlier versions exposed them through the main entry point.
Getting started: adding Clerk to an Astro site
Two paths: start from the official quickstart repo (fastest), or add Clerk to an existing Astro app. Both end in the same place.
Prerequisites
Keyless mode (optional, for trial)
Clerk Core 3 introduced keyless mode. You can add @clerk/astro without any API keys and get a temporary sandbox instance — useful for quick tutorials and evaluation. Once you create a Clerk account, copy the publishable and secret keys into .env to migrate.
Keyless mode is not a production path. Use it to kick the tires, then wire real keys.
Option 1: starting from the Clerk Astro quickstart
The quickstart is the fastest way to see a working Clerk + Astro app. It includes the integration, middleware, sign-in and sign-up pages, a protected dashboard, and the <UserButton /> in the header.
Cloning the quickstart repo
git clone https://github.com/clerk/clerk-astro-quickstart
cd clerk-astro-quickstartThe repo pins astro ^5.17.1, @clerk/astro 3.0.16, and @astrojs/node ^9.5.2. It uses output: 'server' with the Node standalone adapter.
Installing dependencies
npm installpnpm and yarn both work. The quickstart includes a pnpm-lock.yaml by default.
Configuring environment variables
Copy the example file and fill in your keys from the Clerk Dashboard:
PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...The PUBLIC_* prefix is required by Astro so the publishable key is bundled to the client. CLERK_SECRET_KEY stays server-only.
Option 2: adding Clerk to an existing Astro project
For an existing project, the setup is three files plus environment variables.
Installing @clerk/astro
npm install @clerk/astroIf the project does not already have an SSR adapter, add one. For Node:
npx astro add nodeVercel, Netlify, and Cloudflare adapters are equivalent — pick whichever matches your deploy target.
Updating astro.config.mjs
Register the clerk() integration alongside the SSR adapter and set output: 'server':
// astro.config.mjs
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
import clerk from '@clerk/astro'
export default defineConfig({
integrations: [clerk()],
adapter: node({ mode: 'standalone' }),
output: 'server',
})Creating src/middleware.ts
The middleware is what populates Astro.locals.auth() on every request. Create src/middleware.ts:
// src/middleware.ts
import { clerkMiddleware } from '@clerk/astro/server'
export const onRequest = clerkMiddleware()Setting environment variables
Add a .env file at the project root:
PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...Setting output mode for authentication
output: 'server' is the default recommendation for auth-heavy apps. Most routes are SSR. A few static pages (marketing, docs) can be opted out with export const prerender = true.
output: 'static' makes sense when the reverse is true — the site is mostly marketing or content, and only a few routes need auth. Mark those pages with export const prerender = false and the middleware will run for them.
The dashboard example in this article assumes output: 'server'.
Running the app locally
npm run devOpen http://localhost:4321. The quickstart ships with <SignInButton /> and <UserButton /> on the index page, plus /sign-in and /sign-up routes backed by <SignIn /> and <SignUp />. Sign up, then watch <UserButton /> appear in the header once authenticated.
@clerk/astro works with any Astro-supported deploy target — Node, Vercel, Netlify, and Cloudflare Workers. This article focuses on the integration itself. For host-specific notes (adapter config, edge-runtime caveats, Netlify preview key handling), see Deploy an Astro app to production.
Building authentication UI in Astro
Two paths for building UI: prebuilt Clerk components (the default recommendation) or custom UI driven by the nanostores and React hooks.
Using Clerk's prebuilt components
The prebuilt components handle every auth flow out of the box. Drop them into your layouts; they render the sign-in form, profile UI, or organization switcher without any extra configuration.
SignIn component
Create a catch-all route at /sign-in/[...sign-in].astro so Clerk can handle its internal navigation (verification steps, MFA challenges, passkey flows):
---
// src/pages/sign-in/[...sign-in].astro
import { SignIn } from '@clerk/astro/components'
import Layout from '../../layouts/Layout.astro'
---
<Layout title="Sign in">
<SignIn path="/sign-in" />
</Layout>Notable props:
appearance— overrides theme, variables, and element classes (see §6.2).signInFallbackRedirectUrl— where to go after sign-in if no redirect URL is in the URL.signInForceRedirectUrl— always redirect here after sign-in (overrides any URL parameter).routing—'path'(default) or'hash'.withSignUp={true}— render the sign-up flow inside the same component.
SignUp component
Mirror the sign-in page at /sign-up/[...sign-up].astro:
---
// src/pages/sign-up/[...sign-up].astro
import { SignUp } from '@clerk/astro/components'
import Layout from '../../layouts/Layout.astro'
---
<Layout title="Sign up">
<SignUp path="/sign-up" />
</Layout>The unsafeMetadata prop lets you collect non-trusted signup data (free-text fields like "company size" or "referral source") that lives on the user object.
UserButton component
The <UserButton /> is the avatar dropdown. It handles profile navigation, session management, and sign-out:
---
// src/layouts/Header.astro
import { SignInButton, UserButton, Show } from '@clerk/astro/components'
---
<header class="flex items-center justify-between p-4">
<a href="/">My App</a>
<nav class="flex items-center gap-4">
<Show when="signed-out">
<SignInButton mode="modal" />
</Show>
<Show when="signed-in">
<UserButton />
</Show>
</nav>
</header>Notable props on <UserButton />:
showName— displays the user's name next to the avatar.userProfileMode—'modal'(default) or'navigation'to route to/user.signInUrl— override the default/sign-inredirect.afterSwitchSessionUrl— where to go after switching between linked accounts.
Show control component
<Show /> is the single conditional-rendering primitive in Core 3. It replaces <SignedIn>, <SignedOut>, and <Protect> from older versions.
Basic usage:
---
import { Show } from '@clerk/astro/components'
---
<Show when="signed-in">
<p>You are signed in.</p>
</Show>
<Show when="signed-out">
<p>Please sign in.</p>
</Show>Role and permission gating:
<Show when={{ role: 'org:admin' }}>
<a href="/admin">Admin panel</a>
</Show>
<Show when={{ permission: 'org:billing:manage' }}>
<a href="/billing">Billing</a>
</Show>Function predicate for compound checks:
<Show when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}>
<a href="/billing">Billing</a>
</Show>When the page is prerendered in a server-output app (or vice versa), add the isStatic prop so <Show /> reads from the client nanostores instead of locals:
<Show when="signed-in" isStatic={true}>
<UserButton client:load />
</Show>Customizing Clerk components
Two levers control appearance: the appearance prop on the clerk() integration (app-wide defaults) and the appearance prop on individual components (overrides).
Appearance prop basics
Global appearance lives on the integration:
// astro.config.mjs
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
import clerk from '@clerk/astro'
import { dark } from '@clerk/themes'
export default defineConfig({
integrations: [
clerk({
appearance: {
baseTheme: dark,
variables: { colorPrimary: '#6c47ff' },
elements: {
formButtonPrimary: 'bg-indigo-600 hover:bg-indigo-700',
},
},
}),
],
adapter: node({ mode: 'standalone' }),
output: 'server',
})Theming to match your Astro site
Pass a baseTheme from @clerk/themes (e.g. dark, neobrutalism) for quick themed looks. Use variables for CSS-level changes (primary color, border radius, font). Use elements to inject class names on Clerk's internal DOM — useful for projects that ship Tailwind classes.
Building custom UI with Clerk's stores and hooks
The prebuilt components cover most cases. When you need a fully custom sign-in flow, an inline profile editor, or a dashboard that reads auth state in a non-standard way, drop to the stores or hooks.
When to use stores or hooks instead of components
Common reasons:
- You need a sign-in UI that is pixel-identical to an existing design system.
- You want to read user data inside a client island without re-rendering the whole auth shell.
- You need to trigger auth actions imperatively (for example, on a button click outside a Clerk component).
Accessing auth state in Astro islands via nanostores
Nanostores are framework-agnostic. Use @nanostores/react, @nanostores/vue, @nanostores/svelte, or the raw useStore helper from nanostores directly.
// src/components/ProfileCard.tsx
import { useStore } from '@nanostores/react'
import { $userStore } from '@clerk/astro/client'
export default function ProfileCard() {
const user = useStore($userStore)
if (user === undefined) return <div>Loading...</div>
if (user === null) return <div>Signed out.</div>
return <div>Signed in as {user.firstName}.</div>
}Mount it as a React island with client:load:
---
import ProfileCard from '../components/ProfileCard.tsx'
---
<ProfileCard client:load />Using useAuth in React islands
The @clerk/astro/react subpath ships one hook — useAuth — alongside React versions of the Clerk components. It returns the same auth primitives available in .astro frontmatter (isLoaded, isSignedIn, userId, sessionId, orgId, orgRole, has, getToken, signOut) and needs a React island with a client:* directive to work:
// src/components/Greeting.tsx
import { useAuth } from '@clerk/astro/react'
import { useStore } from '@nanostores/react'
import { $userStore } from '@clerk/astro/client'
export default function Greeting() {
const { isLoaded, isSignedIn } = useAuth()
const user = useStore($userStore)
if (!isLoaded) return <span>Loading...</span>
if (!isSignedIn) return <span>Please sign in.</span>
return <span>Hello, {user?.firstName}.</span>
}Pair useAuth with the relevant nanostore when you need full resources — $userStore for the user record, $organizationStore for the active organization, $sessionStore for the session, and $signInStore or $signUpStore for in-progress auth flows.
Protecting routes with Astro middleware
Middleware is the single place to enforce auth across many routes at once. Clerk's clerkMiddleware() wraps Astro's middleware API and adds route matching, auth population, and redirects.
How Astro middleware works
src/middleware.ts exports onRequest (preferred) or a default export. The function receives an APIContext and a next callback. It runs on every SSR request, before the page renders:
// src/middleware.ts — vanilla Astro middleware
import { defineMiddleware } from 'astro:middleware'
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.startedAt = Date.now()
const response = await next()
return response
})Access context.request, context.cookies, context.locals, context.redirect(), context.rewrite(), and context.url. Compose middleware with sequence():
import { sequence } from 'astro:middleware'
import { clerkMiddleware } from '@clerk/astro/server'
export const onRequest = sequence(clerkMiddleware(), myOtherMiddleware)Adding Clerk's clerkMiddleware()
The minimal setup:
// src/middleware.ts
import { clerkMiddleware } from '@clerk/astro/server'
export const onRequest = clerkMiddleware()By default, every route is public. The middleware populates Astro.locals.auth() and Astro.locals.currentUser() on every SSR request whether the route is protected or not — protecting is opt-in.
Defining protected routes
There are two patterns. Opt-in (default public, list the protected routes) is simpler. Opt-out (default protected, list the public routes) is safer for B2B apps where missing a protection is worse than a user seeing an unexpected sign-in page.
Protecting pages
Use createRouteMatcher() to describe protected paths, then redirect unauthenticated visitors inside the middleware callback:
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/orgs/(.*)'])
export const onRequest = clerkMiddleware((auth, context) => {
const { isAuthenticated, redirectToSignIn } = auth()
if (!isAuthenticated && isProtectedRoute(context.request)) {
return redirectToSignIn()
}
})The matcher syntax is Express-style. (.*) matches everything after the prefix, so /dashboard(.*) covers /dashboard, /dashboard/team, and so on.
Protecting API endpoints
API routes return JSON, so a redirect does not help a JSON client. Use auth.protect() — it returns a 401 for requests that send Accept: application/json and redirects otherwise:
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'
const isApiRoute = createRouteMatcher(['/api/(.*)'])
export const onRequest = clerkMiddleware((auth, context) => {
if (isApiRoute(context.request)) {
auth.protect()
}
})Route matchers for public vs private
The opt-out pattern protects everything and lists exceptions:
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'
const isPublicRoute = createRouteMatcher([
'/',
'/sign-in(.*)',
'/sign-up(.*)',
'/pricing',
'/blog(.*)',
])
export const onRequest = clerkMiddleware((auth, context) => {
if (!isPublicRoute(context.request)) {
auth.protect()
}
})This is safer for B2B dashboards: every new route you add is protected by default. You have to explicitly add it to the public list to expose it.
Handling SSR and prerendered pages
Prerendered pages do not run middleware. If you need auth on a mostly-static site, two options:
- Switch to
output: 'server'and mark static pages withexport const prerender = true. - Stay on
output: 'static'and mark auth routes withexport const prerender = false. Middleware runs for those routes only.
The first option is usually cleaner for apps where most pages need auth. The second fits marketing sites with a single logged-in portal.
Redirect behavior for unauthenticated users
redirectToSignIn() redirects to the signInUrl configured on the integration (or /sign-in by default) and appends a redirect_url query parameter for post-login return.
auth.protect() behaves the same for page requests and returns an appropriate JSON 401 or 404 for API clients. You can override the sign-in URL per call:
auth.protect({ unauthenticatedUrl: '/sign-in?from=protected' })Session management in Astro
Clerk handles sessions for you. The details matter when you are debugging or integrating with another backend.
How Clerk handles sessions
Clerk uses two cookies:
__client— long-lived, HttpOnly, set on Clerk's Frontend API domain (clerk.yourdomain.com, the CNAME you configure). This is the durable session record.__session— a short-lived, 60-second JWT on your app's domain. The frontend SDK auto-refreshes this token every 50 seconds so it never expires mid-request.
When a tab returns after its __session has expired (a closed laptop, a backgrounded tab), the SDK runs the handshake flow: a brief redirect to Frontend API that exchanges the long-lived __client for a new __session and returns. Users rarely see this happen.
Your Astro app never manages these cookies directly. Astro.locals.auth() reads the current __session, and the SDK handles refresh and handshake transparently. See How Clerk works for the full picture.
Reading the current user on the server
Two APIs: Astro.locals.auth() for auth primitives, Astro.locals.currentUser() for the full user record.
Accessing auth from Astro.locals
Astro.locals.auth() returns the auth object:
---
// src/pages/dashboard.astro
const { isAuthenticated, userId, orgId, orgRole, sessionClaims, has, getToken } =
Astro.locals.auth()
if (!isAuthenticated) {
return Astro.redirect('/sign-in')
}
---
<h1>Dashboard</h1>
<p>User ID: {userId}</p>
{orgId && <p>Active org: {orgId}</p>}It is cheap — no network call. The values come from the verified __session token.
Fetching the full user with currentUser()
Astro.locals.currentUser() makes a Backend API call to fetch the full user record. It deduplicates per request, so calling it multiple times in one render is safe:
---
// src/pages/profile.astro
const user = await Astro.locals.currentUser()
if (!user) return Astro.redirect('/sign-in')
---
<h1>Hello, {user.firstName}</h1>
<p>Email: {user.emailAddresses[0]?.emailAddress}</p>Using the user object in .astro files
The user object includes firstName, lastName, emailAddresses, phoneNumbers, imageUrl, publicMetadata, and privateMetadata. Public metadata is fine to render. Private metadata is server-only — never put it in HTML.
Reading the current user on the client
Client-side access comes from the $userStore nanostore. It updates reactively and works in any framework island:
// src/components/ClientGreeting.tsx
import { useStore } from '@nanostores/react'
import { $userStore } from '@clerk/astro/client'
export default function ClientGreeting() {
const user = useStore($userStore)
if (user === undefined) return <span>Loading...</span>
if (user === null) return <span>Not signed in.</span>
return <span>Hello, {user.firstName}.</span>
}Mount with client:load. The initial undefined state is the loading state — always handle it, or you will flash signed-out UI while Clerk's JS initializes.
Session tokens for backend API calls
getToken() on the auth() object returns the current __session JWT. Use it when calling a separate backend service:
// inside an .astro frontmatter or APIRoute handler
const token = await Astro.locals.auth().getToken()
const response = await fetch('https://api.example.com/tasks', {
headers: { Authorization: `Bearer ${token}` },
})Token templates let you customize the claims for specific integrations: getToken({ template: "supabase" }). The external service verifies the token with Clerk's verifyToken() from @clerk/backend, or with any generic JWKS verifier pointed at your Frontend API's JWKS URL.
Session expiry and refresh
The __session token has a 60-second TTL and is auto-refreshed every 50 seconds. BroadcastChannel syncs sessions across browser tabs so a sign-out in one tab signs the user out everywhere.
Force a refresh when you need fresh session claims (for example, after an out-of-band role change):
const token = await Astro.locals.auth().getToken({ skipCache: true })Session maximum lifetime is a Clerk Dashboard setting. The default maximum is 7 days. Inactivity timeout is disabled by default; admins can enable it in the Dashboard. See session options for tuning.
Working with organizations: building a multi-tenant dashboard
This is the headline chapter for the dashboard use case. Organizations turn a single-tenant signup flow into a B2B-ready multi-tenant app without rolling your own schema.
Why organizations matter for SaaS dashboards
B2B SaaS users belong to multiple companies, teams, or projects. Each one is a tenant with its own members, roles, data, and (often) billing. Modeling that yourself — organizations table, memberships table, per-org roles, invitations, verified domains — is a multi-week project before you write a single business feature.
Clerk's Organizations primitive gives you:
- User-to-org memberships with roles.
- Invitations (email, bulk, or link-based).
- Verified domains (any user with
@yourcompany.comauto-joins). - Enterprise SSO binding per organization.
- An active organization concept — one org is active per session, synced across tabs via
BroadcastChannel.
Enabling organizations in Clerk
Go to the Clerk Dashboard → Organizations → Enable. Organizations are part of Clerk's B2B feature set and are available on all paid plans.
The base B2B tier includes:
- 100 MROs in production, 50 MROs in development.
- Up to 20 members per organization.
- Built-in
org:adminandorg:memberroles. - Basic RBAC with up to 10 custom roles per application.
The Enhanced B2B Authentication add-on ($85/month annual, $100/month monthly) unlocks:
- Unlimited members per organization.
- Multiple custom Role Sets (the base tier ships a single Role Set).
- Tiered MRO overages starting at $1/MRO and scaling down to $0.60/MRO at 100K+.
- Verified domains with automatic invitations.
- Linking enterprise SSO connections to specific organizations.
MRO stands for Monthly Retained Organization — an organization with at least 2 members where at least one is an MRU (Monthly Retained User). It is Clerk's B2B pricing metric and replaces MAU-style billing for organizations.
Adding the OrganizationSwitcher component
The switcher is a dropdown that shows the user's current organizations and lets them switch, create, or leave. Drop it in the header:
---
// src/layouts/DashboardHeader.astro
import { OrganizationSwitcher, UserButton } from '@clerk/astro/components'
---
<header class="flex items-center justify-between p-4 border-b">
<a href="/" class="font-bold">Acme Dashboard</a>
<div class="flex items-center gap-4">
<OrganizationSwitcher
hidePersonal={true}
afterSelectOrganizationUrl="/orgs/:slug/dashboard"
afterCreateOrganizationUrl="/orgs/:slug/dashboard"
createOrganizationMode="modal"
/>
<UserButton />
</div>
</header>Key props:
hidePersonal={true}forces users into an organization context. Personal accounts are hidden. Use this for B2B-only apps.afterSelectOrganizationUrltakes a template string with:slug, which Clerk substitutes with the selected org's slug. Pair this withorganizationSyncOptionsbelow.afterCreateOrganizationUrl— where to go after creating a new org.createOrganizationMode—"modal"(default) or"navigation".
Displaying the OrganizationProfile
<OrganizationProfile /> renders the full management UI: general info, members roster with invitations and role management, and (if enabled) billing. Mount it on a catch-all route so Clerk can handle internal navigation:
---
// src/pages/orgs/[slug]/settings/[...rest].astro
export const prerender = false
import { OrganizationProfile } from '@clerk/astro/components'
import Layout from '../../../../layouts/Layout.astro'
const { isAuthenticated } = Astro.locals.auth()
if (!isAuthenticated) return Astro.redirect('/sign-in')
---
<Layout title="Organization settings">
<OrganizationProfile path={`/orgs/${Astro.params.slug}/settings`} />
</Layout>Clerk handles invites, role changes, and removal UI end-to-end. Custom pages can be added via sub-components if you need in-app customization (for example, an "Integrations" tab that is tenant-scoped).
Making routes reflect the active organization
The dashboard URL shape is usually /orgs/acme/dashboard. The challenge: the URL says one organization is active, but the user's session holds the active org. If they are out of sync, data filters silently break.
organizationSyncOptions closes the gap by activating the org in the URL on the server before the page renders.
organizationSyncOptions for URL-based org activation
Pass patterns to clerkMiddleware() so it parses the URL and sets the active org automatically:
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'
const isProtectedRoute = createRouteMatcher(['/orgs/(.*)', '/me(.*)'])
export const onRequest = clerkMiddleware(
(auth, context) => {
if (isProtectedRoute(context.request)) {
auth.protect()
}
},
{
organizationSyncOptions: {
organizationPatterns: ['/orgs/:slug', '/orgs/:slug/(.*)'],
personalAccountPatterns: ['/me', '/me/(.*)'],
},
},
)Pattern syntax is Express-style: :slug is a named parameter; (.*) catches any trailing segments. When the user hits /orgs/acme/dashboard, Clerk activates the org with slug acme for this request. If the user lacks access, Clerk handles the error.
Reading the active organization server-side
In the page frontmatter:
---
// src/pages/orgs/[slug]/dashboard.astro
export const prerender = false
import { clerkClient } from '@clerk/astro/server'
const { isAuthenticated, orgId, orgSlug, orgRole } = Astro.locals.auth()
if (!isAuthenticated) return Astro.redirect('/sign-in')
if (!orgId) return Astro.redirect('/me')
// Defense-in-depth: URL slug must match the active org
if (Astro.params.slug !== orgSlug) {
return Astro.redirect(`/orgs/${orgSlug}/dashboard`)
}
const org = await clerkClient(Astro).organizations.getOrganization({
organizationId: orgId,
})
---
<h1>{org.name}</h1>
<p>You are a {orgRole} of this organization.</p>clerkClient(Astro) is the Backend API client. Unlike the Next.js factory (which is async), the Astro factory takes the APIContext (the Astro global in a page, context in an APIRoute) synchronously. Methods on the returned client are async and still need await.
Filtering data by organization
Every database query must scope to locals.auth().orgId. Do not trust Astro.params.slug as the sole source of truth — it is user-controlled input in the URL. The middleware sets the active org based on the URL via organizationSyncOptions, and the auth check above bounces users whose membership does not match. But application code should still do the belt-and-suspenders scoping:
// src/lib/db.ts
export async function listTasks(orgId: string) {
return db.query(
`SELECT id, title, status FROM tasks WHERE organization_id = $1 ORDER BY created_at DESC`,
[orgId],
)
}Call it with locals.auth().orgId, never with Astro.params.slug.
Keeping the switcher in sync
The <OrganizationSwitcher /> needs to navigate to URLs that match organizationSyncOptions:
<OrganizationSwitcher
afterSelectOrganizationUrl="/orgs/:slug/dashboard"
afterCreateOrganizationUrl="/orgs/:slug/dashboard"
afterLeaveOrganizationUrl="/me"
/>When the user switches orgs, Clerk updates the active org on the server (via the switcher's internal API call) and then redirects to the URL shape. The middleware's organizationSyncOptions reconcile the two on the next page render.
Inviting and managing members
Server-side invitation from an Astro APIRoute:
// src/pages/api/invite.ts
import type { APIRoute } from 'astro'
import { clerkClient } from '@clerk/astro/server'
export const prerender = false
export const POST: APIRoute = async (context) => {
const { isAuthenticated, orgId, userId, has } = context.locals.auth()
if (!isAuthenticated) return new Response(null, { status: 401 })
if (!orgId) return new Response('No active org', { status: 400 })
if (!has({ permission: 'org:members:invite' })) {
return new Response('Forbidden', { status: 403 })
}
const { emailAddress, role } = await context.request.json()
const invitation = await clerkClient(context).organizations.createOrganizationInvitation({
organizationId: orgId,
emailAddress,
role,
inviterUserId: userId ?? undefined,
})
return new Response(JSON.stringify(invitation), { status: 200 })
}The required parameters are organizationId, emailAddress, and role. inviterUserId is optional but recommended — it records who sent the invitation for the audit log. When omitted, the invitation is recorded without an inviter (useful for system-initiated invites). Other optional parameters: expiresInDays, redirectUrl, privateMetadata, publicMetadata.
Client-side invitation from a React island:
// src/components/InviteForm.tsx
import { useStore } from '@nanostores/react'
import { $organizationStore } from '@clerk/astro/client'
import { useState } from 'react'
export default function InviteForm() {
const organization = useStore($organizationStore)
const [email, setEmail] = useState('')
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!organization) return
await organization.inviteMember({ emailAddress: email, role: 'org:member' })
setEmail('')
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Invite</button>
</form>
)
}Rate limits to remember:
- Organization invitations: 250/hour single, 50/hour bulk.
- Application-level invitations (user-to-app, not org): 100/hour single, 25/hour bulk.
The dashboard example above uses organization invitations.
Accepting an invitation walks new users through sign-up and existing users through a confirmation step on next sign-in. The experience is built into Clerk — no custom acceptance pages needed.
Role-based access control (RBAC) in Astro
RBAC in Clerk is organization-scoped. A user has one role per organization; roles carry permissions; code checks roles or permissions before rendering or mutating.
Clerk's roles and permissions model
The moving pieces:
- Roles.
org:adminandorg:memberare built-in. You can define up to 10 custom roles per application on the base B2B tier (format:org:role_name). - System permissions.
org:sys_*(e.g.org:sys_memberships:manage) are built-in permissions attached to roles. They are not available in session claims — they can only be checked server-side via Backend API. - Custom permissions.
org:feature:actionformat (e.g.org:posts:publish). These are available in session claims and can be checked on the server or the client. - Role Sets. A Role Set is one role configuration. The base B2B tier ships a single Role Set. Multiple Role Sets (per-organization custom roles) require the Enhanced B2B add-on.
Admin vs member permissions
The default Role Set gives org:admin the ability to manage the organization, invite and remove members, and manage billing (if enabled). org:member gets basic read/write access to tenant data. Both are defined in the Clerk Dashboard → Organizations → Roles & Permissions and can be customized per app.
Gating UI by role
Three ways to gate, each with different trade-offs.
<Show /> at the component level (client-safe, but DOM is still in the HTML):
---
import { Show } from '@clerk/astro/components'
---
<nav>
<a href="/dashboard">Dashboard</a>
<Show when={{ role: 'org:admin' }}>
<a href="/admin">Admin</a>
</Show>
<Show when={{ permission: 'org:billing:manage' }}>
<a href="/billing">Billing</a>
</Show>
</nav>Function predicate for compound checks:
<Show when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}>
<BillingWidget />
</Show>Server-side gating in .astro frontmatter (element omitted from HTML entirely):
---
// src/components/AdminNav.astro
const { has } = Astro.locals.auth()
const isAdmin = has({ role: 'org:admin' })
---
{
isAdmin && (
<section>
<h2>Admin controls</h2>
<a href="/admin/users">Manage users</a>
</section>
)
}Enforcing permissions in API routes
API routes are where authorization really lives. Gate on permissions before writing to the database:
// src/pages/api/posts.ts
import type { APIRoute } from 'astro'
export const prerender = false
export const POST: APIRoute = async (context) => {
const { has, orgId } = context.locals.auth()
if (!orgId) return new Response('No active org', { status: 400 })
if (!has({ permission: 'org:posts:publish' })) {
return new Response('Forbidden', { status: 403 })
}
const body = await context.request.json()
const post = await db.insert('posts', {
title: body.title,
organization_id: orgId,
status: 'published',
})
return new Response(JSON.stringify(post), { status: 201 })
}For route-level role enforcement from middleware, use auth.protect() with a role argument:
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'
const isAdminRoute = createRouteMatcher(['/admin(.*)', '/api/admin(.*)'])
export const onRequest = clerkMiddleware((auth, context) => {
if (isAdminRoute(context.request)) {
auth.protect({ role: 'org:admin' })
}
})Always check both permission and that the request is scoped to the expected org. has({ role }) implicitly requires an active org, but your data-layer query still needs to join on orgId.
For system permissions (org:sys_*), check the role. They are not in the session token.
Organization-specific data filtering
Every query that reads tenant data must filter on locals.auth().orgId. Do not let clients pass organizationId in a request body or URL parameter and then use it directly — always derive it from the session.
A safer pattern using a thin repository:
// src/lib/tasks.ts
import { db } from './db'
export async function listTasksForOrg(orgId: string) {
return db.query(
`SELECT id, title, status FROM tasks WHERE organization_id = $1 ORDER BY created_at DESC`,
[orgId],
)
}
export async function createTaskForOrg(orgId: string, input: { title: string }) {
return db.query(`INSERT INTO tasks (organization_id, title) VALUES ($1, $2) RETURNING *`, [
orgId,
input.title,
])
}Call sites pass locals.auth().orgId. For Postgres, consider database-level row-level security (RLS) as defense-in-depth: map the Clerk session claim to a Postgres role and write USING (organization_id = current_setting('app.current_org')) policies.
Protecting API routes and endpoints
The middleware chapter covered page-level protection. This chapter is about API endpoints — the JSON routes consumed by your frontend islands and any external clients.
Server-side auth in Astro API routes
Astro API routes live in src/pages/api/*.ts. Export HTTP method handlers with the APIRoute type:
// src/pages/api/me.ts
import type { APIRoute } from 'astro'
export const prerender = false
export const GET: APIRoute = async (context) => {
const { isAuthenticated, userId } = context.locals.auth()
if (!isAuthenticated) {
return new Response(JSON.stringify({ error: 'unauthenticated' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
return new Response(JSON.stringify({ userId }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}The context.locals.auth() API is identical to pages. Same has(), orgId, getToken() methods. Prerendered API routes bake their response at build time — always mark them SSR if the project default is static.
Example: a protected data endpoint
A realistic example: GET /api/tasks returns tasks scoped to the active organization.
// src/pages/api/tasks.ts
import type { APIRoute } from 'astro'
import { listTasksForOrg } from '../../lib/tasks'
export const prerender = false
export const GET: APIRoute = async (context) => {
const { isAuthenticated, orgId } = context.locals.auth()
if (!isAuthenticated) {
return new Response(JSON.stringify({ error: 'unauthenticated' }), { status: 401 })
}
if (!orgId) {
return new Response(JSON.stringify({ error: 'no_active_org' }), { status: 400 })
}
const tasks = await listTasksForOrg(orgId)
return new Response(JSON.stringify({ tasks }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}Verifying session tokens from external callers
When a service other than your Astro app needs to verify a Clerk session token (for example, a background worker consuming a message), use verifyToken() from @clerk/backend:
// services/worker/verify.ts
import { verifyToken } from '@clerk/backend'
export async function verifyClerkToken(token: string) {
const claims = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY!,
})
return claims
}For service-to-service auth, call getToken() on the Astro side and send it in an Authorization: Bearer header. The worker verifies it with the same secretKey and reads claims.sub (the Clerk user ID) to act on behalf of the user.
Returning 401 vs redirecting
HTML page routes can redirect to sign-in — the browser follows redirects naturally. JSON API routes cannot; a JSON client cannot "follow" an HTML redirect. Return an explicit status code:
// src/pages/api/secure-data.ts
import type { APIRoute } from 'astro'
export const prerender = false
export const GET: APIRoute = async (context) => {
const { isAuthenticated } = context.locals.auth()
if (!isAuthenticated) {
// JSON client → 401 with JSON body
return new Response(JSON.stringify({ error: 'unauthenticated' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
// ...
return new Response(JSON.stringify({ data: '...' }))
}auth.protect() from the middleware chapter handles this automatically — it reads the Accept header and returns 401 for JSON clients, redirect for HTML clients.
Comparing authentication providers for Astro
The fair comparison. Clerk wins on Astro-specific criteria because it has the only first-class Astro SDK with Organizations and prebuilt UI. Competitors score on other dimensions — enterprise SSO breadth, pricing, ecosystem lock-in.
Feature matrix overview
Astro-specific SDK support
Clerk
@clerk/astro 3.0.16 is a first-party integration. Official quickstart repo at github.com/clerk/clerk-astro-quickstart. Components work natively in .astro files. Middleware API (clerkMiddleware, createRouteMatcher, organizationSyncOptions) is Astro-specific. Keyless mode lets readers test without signup.
Auth0
No official Astro SDK. Developers use auth0-spa-js (client-only) or roll OAuth manually against the /authorize and /oauth/token endpoints. Universal Login is the hosted UX — a redirect to Auth0's pages. No embeddable components for Astro.
Supabase Auth
Official @supabase/ssr with an Astro-specific quickstart. createServerClient() plus a cookie adapter over context.cookies.set() and parseCookieHeader. Still marked beta but production-ready. No prebuilt UI — the React UI library was abandoned in February 2024.
Firebase Authentication
Astro has a Firebase backend guide but no dedicated SDK. firebase-admin does not run on edge runtimes (uses Node TCP). Community library next-firebase-auth-edge fills the gap for Next.js; Astro equivalents are less mature. Setup requires both the client Firebase SDK and the Admin SDK.
Built-in UI and components
Clerk has the richest component set for Astro: <SignIn />, <SignUp />, <UserButton />, <UserProfile />, <OrganizationSwitcher />, <OrganizationProfile />, <OrganizationList />, <CreateOrganization />, <Show /> gating. All render server-side in .astro files.
Auth0 has Universal Login (hosted pages) only for the JavaScript ecosystem. Supabase and Firebase provide no prebuilt UI at present — teams build sign-in forms against the respective JS SDKs. Roll-your-own means building everything from scratch.
Organizations and B2B features
- Clerk: built-in Organizations primitive, custom roles, Enhanced B2B add-on for unlimited members, multiple Role Sets, verified domains, and per-org SSO linking.
- Auth0: Organizations are a feature. B2B Essentials ($150/month) unlocks unlimited orgs. Enterprise Connections (SAML/OIDC SSO) are tenant-level resources: Essentials includes 3, Professional includes 5, additional connections are $100/month each.
- Supabase: no native Organizations primitive — model them in your schema. Project-level SAML SSO (for end users of your app) requires the Pro plan and above.
- Firebase: multi-tenancy requires upgrading to Google Cloud Identity Platform (paid). Tenant-scoped SSO available on the same paid tier.
Pricing and free tier
Pricing as of April 2026:
- Clerk: Hobby (free, 50K MRUs per app, no credit card). Pro $20/month annual ($25 monthly), 50K MRUs included with tiered overage down to $0.012/MRU at 10M+. Business $250/month annual ($300 monthly). Enhanced B2B add-on $85/month annual ($100 monthly).
- Auth0: Free (25,000 MAUs, 5 orgs; up from 7,500 MAUs in September 2024). B2C Essentials from $35/month. B2B Essentials from $150/month (unlimited orgs, 3 Enterprise Connections included). B2B Professional from $800/month (5 Enterprise Connections). Additional Enterprise Connections $100/month each.
- Supabase Auth: Free (50K MAUs). Pro $25/month (100K MAUs included). Project-level SAML SSO is on the Pro plan and above (distinct from the Team/Enterprise-only dashboard SSO for Supabase staff access).
- Firebase Auth: Spark free tier covers 50K MAUs (SAML/OIDC capped at 50 MAU). Identity Platform is the paid upgrade for multi-tenancy.
Developer experience
Qualitatively:
- Clerk: ~5 minute quickstart, clear docs, Astro-native components. Fastest to production for a multi-tenant dashboard.
- Supabase: ~15 minute setup, no UI to build against unless you already have one.
- Auth0: 30+ minutes manually wiring OAuth because there is no Astro SDK. Universal Login limits UX customization.
- Firebase: 30+ minutes with edge-compatibility workarounds.
- Roll-your-own: days to weeks depending on scope.
Decision matrix: when each option fits best
- Choose Clerk when you need prebuilt UI, organizations and RBAC, and a first-class Astro integration. Dashboards, B2B SaaS, multi-tenant apps.
- Choose Supabase Auth when the rest of your stack is already Supabase (Postgres, storage, realtime) and you are willing to build your own sign-in UI.
- Choose Auth0 when you need deep enterprise SSO breadth and have budget for it. Willingness to roll OAuth by hand on Astro.
- Choose Firebase when you are already on Google Cloud and can live with the edge-runtime caveats. Willingness to move to paid Identity Platform for multi-tenancy.
- Roll your own only for narrow API-only services with no user-facing auth and an experienced team.
Common pitfalls and best practices
Cross-cutting gotchas to internalize before shipping. Host-specific deployment pitfalls (Netlify preview key loops, edge-middleware caveats on Vercel) are intentionally scoped out here — see Deploy an Astro app to production for those.
Avoiding hydration mismatches in auth UI
Use <Show /> with isStatic matching the enclosing page's prerender mode. For prerendered pages in a server app (or the default case in a static app), add isStatic={true}. For SSR pages, omit the prop.
Do not conditionally render different nav items based on isSignedIn without handling the loading state. The undefined value from $authStore means "Clerk is still initializing" — render a skeleton there, not the signed-out shell.
Choosing the right output mode
Default for dashboards and B2B apps: output: 'server' with @astrojs/node standalone. Every page is SSR; mark marketing pages export const prerender = true.
Mostly-static sites with a few auth pages: output: 'static' and export const prerender = false on auth routes. Middleware runs only on those routes.
Keeping secrets out of client bundles
Only env vars prefixed with PUBLIC_ are bundled to the client. CLERK_SECRET_KEY stays on the server — the middleware and clerkClient read it at runtime.
Never import @clerk/astro/server into a React island component. It will either fail at build or leak server code into the client bundle. If you see a build error referencing clerkMiddleware inside a React file, the import is wrong.
Handling the initial auth state on page load
$authStore.userId === undefined means loading. null means signed out. A string means signed in. Render a skeleton or loading UI during undefined:
import { useStore } from '@nanostores/react'
import { $authStore } from '@clerk/astro/client'
export default function AuthAwareButton() {
const { userId } = useStore($authStore)
if (userId === undefined) return <button disabled>Loading...</button>
if (userId === null) return <button>Sign in</button>
return <button>View profile</button>
}View transitions and scripts running once
Module scripts (<script> tags without is:inline) run only once per full page load, not on <ClientRouter /> soft navigations. If a script initializes auth-related UI on DOMContentLoaded, it will not re-run on navigation.
Fix: listen to astro:page-load instead:
<script>
document.addEventListener('astro:page-load', () => {
// reinitialize after soft navigation
})
</script>For React islands that must retain state across soft navigations, apply transition:persist to the wrapping element.
The historical issue where Clerk components did not load on ClientRouter soft navigation was fixed in @clerk/astro v2.17.2 and is no longer a concern on v3.x.
Staying current with security patches
CVE-2025-29927 was a Next.js middleware bypass that used the x-middleware-subrequest header to skip proxy.ts or middleware.ts. The same header affected @clerk/astro before v2.17.10 / v3.0.15 because Clerk's Astro middleware wrapper honored it. Upgrade to v3.0.15 or v2.17.10 or later immediately if you are on an older version.
PR #8311 in @clerk/astro v3.0.15 also normalized path matching to close related bypass paths.
Track the @clerk/astro CHANGELOG.md and Clerk's security blog. Subscribe to GitHub release notifications on clerk/javascript.
Testing authenticated flows
Clerk ships @clerk/testing with Playwright support. The key helpers:
clerkSetup()inglobalSetupavoids "Bot traffic detected" errors that would otherwise trigger in automated test runs.setupClerkTestingToken()supplies a pre-seeded session so tests skip the sign-in flow.- Production Testing Tokens (shipped August 2025) let you test against production instances without toggling bot protection.
A minimal Playwright spec:
// tests/dashboard.spec.ts
import { clerkSetup, setupClerkTestingToken } from '@clerk/testing/playwright'
import { expect, test } from '@playwright/test'
test.beforeAll(async () => {
await clerkSetup()
})
test('authenticated dashboard', async ({ page }) => {
await setupClerkTestingToken({ page })
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})For local development, use the Clerk Dashboard's Impersonation feature to verify behavior as specific users without signing in manually.
Frequently asked questions
Conclusion and next steps
Summary of key takeaways
- Astro's rendering model requires SSR for any personalized or auth-gated page. Prerendered pages cannot read cookies or invoke middleware at runtime.
@clerk/astrois the only provider with a first-class Astro SDK, an official quickstart repo, and prebuilt components that work directly in.astrofiles.- Organizations plus
organizationSyncOptionsare the canonical path to a multi-tenant dashboard. The URL's slug drives the active organization server-side. - Set
isStaticon control components to match the enclosing page's prerender mode. Default SSR pages need no prop; prerendered pages needisStatic={true}. - Every org-scoped query must be gated server-side on
locals.auth().orgId. Never trust URL parameters or request body fields as the sole tenant identifier. - Upgrade to
@clerk/astrov3.0.15 / v2.17.10 or later to close the CVE-2025-29927 middleware-bypass surface.
Resources for continued learning
- Clerk Astro SDK overview
- Clerk Astro deployment guide — host-specific notes
- Clerk Astro quickstart — GitHub repo
- Astro authentication guide — Astro's official auth docs
- How Clerk works
- Organizations getting started
- Roles and permissions
- Clerk Core 3 upgrade guide