Skip to main content
Articles

Authentication for Astro Sites

Author: Roy Anger
Published:

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/astro SDK 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 with export const prerender = false.
  • output: 'server'. Every route is server-rendered on each request unless you opt a single page in with export 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.

Note

output: 'hybrid' was removed in Astro v5 in favor of per-page prerender exports. Some Clerk docs still reference "hybrid rendering" as a mental model for mixed static and SSR, but the configuration field is gone. Use output: 'server' or output: 'static' with per-page opt-outs.

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:load hydrates immediately. Best for nav-level UI that needs to react on page load.
  • client:idle waits for requestIdleCallback.
  • client:visible waits 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).

Warning

Astro.request.headers and Astro.cookies are unavailable on prerendered pages. The Clerk middleware does not run for them. If you see Astro.locals.auth is not a function, check that the route is SSR and that clerkMiddleware() is exported from src/middleware.ts.

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-kinde adapter.
  • 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:

  1. 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.
  2. 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.
  3. Do you need a first-party Astro integration? Clerk (@clerk/astro) and Supabase (@supabase/ssr) are the only ones with one.
  4. 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.
  5. What's your budget and scale? Pricing matrix in §12.5.
  6. 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 the clerk() 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 the useAuth hook 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/webhooksverifyWebhook() for handling Clerk webhooks.
  • @clerk/astro/types — TypeScript types including ClerkAuthorization for 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 marked export const prerender = true: pass isStatic={true}.
  • Project is output: 'static' and the page is marked export const prerender = false: default is correct.
  • Project is output: 'static' and the page is prerendered: pass isStatic={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 {}

Tip

Augmenting ClerkAuthorization makes has({ role }) and <Show when={{ role }}> refuse to compile with an invalid role string. If you add a new role later, the type error points to every guard that needs to be updated.

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

Checklist

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-quickstart

The 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 install

pnpm 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.

Tip

API keys live at dashboard.clerk.com/~/api-keys. A new Clerk application starts with a development instance whose keys begin with pk_test_ and sk_test_. Production keys (pk_live_ and sk_live_) are generated when you create a production instance.

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/astro

If the project does not already have an SSR adapter, add one. For Node:

npx astro add node

Vercel, 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_...

Important

Never commit CLERK_SECRET_KEY. Add .env to .gitignore. Only PUBLIC_* env vars are bundled to the client; secrets stay on the server. Importing @clerk/astro/server into a React island will fail at build — the server SDK is not for client code.

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 dev

Open 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.

Note

In Core 3, afterSignInUrl and afterSignUpUrl were removed. Use signInFallbackRedirectUrl for a post-sign-in default and signInForceRedirectUrl to always redirect to a specific URL.

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-in redirect.
  • afterSwitchSessionUrl — where to go after switching between linked accounts.

Note

afterSignOutUrl moved from the <UserButton /> component to the clerk() integration in Core 3. Configure it once on the integration and it applies everywhere. The as prop is removed in Core 3 — use asChild with a slotted element.

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.

Tip

Put appearance on the clerk() integration for app-wide defaults. Override per-component only when a specific mount needs to look different — for example, a dark-themed <SignIn /> on a light marketing page.

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.

Note

useAuth and the nanostores only work inside client-side islands. In .astro frontmatter, use Astro.locals.auth() on the server. Mixing the client-only helpers into .astro frontmatter will throw at build time.

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)

Warning

Middleware only runs on SSR requests. Prerendered pages bypass middleware entirely — the HTML was baked at build time and never hits your server. Any route that needs auth must be SSR.

// 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:

  1. Switch to output: 'server' and mark static pages with export const prerender = true.
  2. Stay on output: 'static' and mark auth routes with export 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' })

Tip

Always allow /sign-in(.*) and /sign-up(.*) through your protection check. If the sign-in page itself redirects unauthenticated visitors to /sign-in, you get an infinite redirect loop. The opt-out pattern above shows the correct shape.

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.

Important

__session is not marked HttpOnly because the frontend SDK needs JavaScript access to attach it to outgoing requests. The 60-second TTL and 50-second refresh interval mitigate the XSS risk by limiting the window a stolen token is usable. Keeping your app free of XSS vulnerabilities is still the first line of defense.

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.

Warning

user.privateMetadata is only available on the server-side Backend User object (the one returned by currentUser() or clerkClient().users.getUser()). It should never reach client JavaScript. Rendering it inline in .astro HTML leaks it to every visitor.

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.com auto-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 → OrganizationsEnable. 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:admin and org:member roles.
  • 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.

Note

Organizations are not available on the free Hobby plan. A paid plan (Pro or higher) is required to enable them in production.

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.
  • afterSelectOrganizationUrl takes a template string with :slug, which Clerk substitutes with the selected org's slug. Pair this with organizationSyncOptions below.
  • 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.

Warning

Using Astro.params.slug for data isolation is a common tenant-leakage bug. A user with membership in acme who visits /orgs/evil-corp/dashboard could see evil-corp's data if the app queries by the URL slug. Always derive the tenant ID from the session (orgId).

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.

Important

organizationSyncOptions activates the URL's org server-side. The client-side <OrganizationSwitcher /> still needs afterSelectOrganizationUrl to match the URL shape. Missing the URL template breaks the "switch and stay on the same page" flow.

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:

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.

Note

For fetching the current user, prefer Astro.locals.currentUser() over clerkClient(Astro).users.getUser(userId). currentUser() deduplicates per request and does not count against your Backend API quota on repeated calls within the same render. Use clerkClient when you need users or organizations other than the active session.

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:admin and org:member are 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:action format (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.

Warning

has({ permission: "org:sys_*" }) on the server returns false for system permissions because they are not in the session token. Use has({ role: "org:admin" }) instead for system-level actions. Custom permissions (org:feature:action) are in the token and work as expected.

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>
  )
}

Warning

<Show /> only hides content at render time. The HTML is still present in the page source. Browser inspectors can see it. For truly sensitive data (financial details, secrets, PII), gate it server-side in frontmatter and do not render it at all. The best gate is the one that runs closest to the data — the API route or database query.

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

ProviderOfficial Astro SDKPrebuilt UIOrganizationsRBACSSO/SAML (tier)Free tierPaid entry
Clerk (base B2B) (built-in)Pro+50K MRUs$20/mo
Auth0Hosted onlyPaid B2BEssentials+25K MAUs$35/mo
Supabase AuthCustom claimsPro+50K MAUs$25/mo
FirebaseLegacy onlyIdentity PlatformCustom claimsIdentity Platform50K MAUsPay-as-you-go
Roll-your-ownDIYN/AInfrastructure + dev time

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:

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.

Tip

A quick sanity check: grep -r "@clerk/astro/server" src/components should return nothing. Anything in src/components that imports the server subpath has the wrong file extension or is being imported from the wrong place.

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.

Warning

CVE-2025-29927 affected @clerk/astro before v2.17.10 / v3.0.15. If your package.json shows an older version, upgrade before shipping. Attackers could use the bypass to reach routes gated by clerkMiddleware() without authentication.

Testing authenticated flows

Clerk ships @clerk/testing with Playwright support. The key helpers:

  • clerkSetup() in globalSetup avoids "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/astro is the only provider with a first-class Astro SDK, an official quickstart repo, and prebuilt components that work directly in .astro files.
  • Organizations plus organizationSyncOptions are the canonical path to a multi-tenant dashboard. The URL's slug drives the active organization server-side.
  • Set isStatic on control components to match the enclosing page's prerender mode. Default SSR pages need no prop; prerendered pages need isStatic={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/astro v3.0.15 / v2.17.10 or later to close the CVE-2025-29927 middleware-bypass surface.

Resources for continued learning