
Authentication for Astro Sites - Part 2
Part 2 of 4. Start with Authentication for Astro Sites.
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.4.9 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>Passing path is all you need — setting path makes Clerk use path-based routing automatically, so you don't add routing="path" separately. The path value must match where the route is mounted (/sign-in).
Notable props:
appearance— overrides theme, variables, and element classes (see Customizing Clerk components below).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'or'hash'. Supplyingpath(as above) selects'path'; choose'hash'only when mounting without apath.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 redirecting to a sign-in page does not help a JSON client. @clerk/astro has no protect() helper — the auth parameter is a function that returns the auth object, so check auth().isAuthenticated and return a 401 Response yourself for unauthenticated API requests:
// 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().isAuthenticated) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
})You can also enforce auth inside the endpoint itself by reading Astro.locals.auth() in the route handler and returning a 401 there — handy when only a few endpoints need protection.
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) => {
const { isAuthenticated, redirectToSignIn } = auth()
if (!isPublicRoute(context.request) && !isAuthenticated) {
return redirectToSignIn()
}
})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() sends unauthenticated visitors to your sign-in URL and appends a redirect_url query parameter so they return to their original destination after signing in. Configure that URL with the signInUrl option on the clerk() integration or the PUBLIC_CLERK_SIGN_IN_URL environment variable — for example, PUBLIC_CLERK_SIGN_IN_URL=/sign-in to use the in-app /sign-in route from earlier. Without it, Clerk falls back to its hosted Account Portal sign-in page.
To override the return destination Clerk appends — where the user lands after signing in — pass returnBackUrl:
return auth().redirectToSignIn({ returnBackUrl: '/dashboard' })Conclusion
With the Clerk Astro SDK configured, you can build powerful authentication flows and secure pages with minimal boilerplate. Prebuilt components like <SignIn /> and <UserButton /> get you to production quickly, while the client-side nanostores and React hooks give you the flexibility to build entirely custom UIs when needed. Astro middleware ties it all together, ensuring that protected routes remain secure whether you are building a server-rendered dashboard or a hybrid static site.
In the next part of this series, we will dive into the core mechanics of session management, building multi-tenant dashboards with Organizations, and implementing role-based access control (RBAC).
Frequently asked questions
In this series
- Authentication for Astro Sites
- Authentication for Astro Sites - Part 2 (you are here)
- Authentication for Astro Sites - Part 3
- Authentication for Astro Sites - Part 4