
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.@clerk/astro/webhooks—verifyWebhook()for handling Clerk webhooks.@clerk/astro/types— TypeScript types includingClerkAuthorizationfor type-safe role and permission checks.
The current major is @clerk/astro v4 (see the latest release on npm). Minimum requirements: Node.js 20.9.0 or higher; Astro 5, 6, or 7. Astro 6 and 7 themselves require 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 and later. 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
Accountless setup (optional, for trial)
Run the Clerk CLI from your Astro project:
npx -y clerk@latest init --accountlessThe --accountless flag provisions a claimable application, writes PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY to .env, and configures the Astro integration. This flow is useful for tutorials and evaluation because you don't need to create a Clerk account or copy API keys. Before deploying to production, run clerk auth login to claim the application, then use clerk deploy to configure its production instance.
If you take this path, clerk init covers the install, integration, middleware, and environment steps shown in both options below — skim them to understand what was set up, then continue from "Setting output mode for authentication."
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, @clerk/astro, and the Node adapter to versions that work together. 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
If you used accountless setup, your .env is already populated — skip this step. Otherwise, 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 — clerk init --accountless creates all of them; the steps below are the manual equivalent.
Installing @clerk/astro
npm install @clerk/astroIf the project does not already have an SSR adapter, add one, matching the adapter major to your Astro version — for Node, @astrojs/node v11 for Astro 7, v10 for Astro 6, v9 for Astro 5. If npm reports a peer conflict, update Astro to its latest minor first; the newest adapter releases require recent Astro minors. On Astro 7:
npm install @astrojs/node@11Vercel, 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 pages and API routes
Clerk's clerkMiddleware() wraps Astro's middleware API and populates auth state on every SSR request. Enforcing auth happens at each protected page or endpoint — @clerk/astro v4 removed the createRouteMatcher() middleware helper. If you're migrating from a matcher-based setup, that reference section shows the replacement patterns.
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.
Protecting resources
Every route is public until the resource itself checks auth. The check lives in the page or endpoint that accesses protected data, so it can never fall out of sync with how Astro resolves the request path.
Protecting pages
Read Astro.locals.auth() in the page's frontmatter and redirect unauthenticated visitors before rendering:
---
// src/pages/dashboard.astro
const { isAuthenticated, redirectToSignIn } = Astro.locals.auth()
if (!isAuthenticated) return redirectToSignIn()
---
<h1>Dashboard</h1>Every page under a protected area needs its own check — a layout component is not enough, because layouts only wrap the pages that opt in to them.
Protecting API endpoints
API routes return JSON, so redirecting to a sign-in page does not help a JSON client. Check locals.auth().isAuthenticated in the handler and return a 401 Response:
// src/pages/api/data.ts
import type { APIRoute } from 'astro'
export const GET: APIRoute = ({ locals }) => {
const { isAuthenticated, userId } = locals.auth()
if (!isAuthenticated) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
return Response.json({ userId })
}Sharing the check with a helper
Repeating the same two lines across many pages invites drift. Extract a requireAuth() helper and call it from each protected resource:
// src/lib/requireAuth.ts
import type { APIContext } from 'astro'
export function requireAuth(context: APIContext) {
const { isAuthenticated, redirectToSignIn } = context.locals.auth()
if (!isAuthenticated) return redirectToSignIn()
}---
// src/pages/dashboard.astro
import { requireAuth } from '../lib/requireAuth'
const redirect = requireAuth(Astro)
if (redirect) return redirect
---
<h1>Dashboard</h1>Role and permission checks with has() follow the same shape — check at the resource, not in middleware. Middleware logic unrelated to auth protection, such as locale redirects or headers, can stay in src/middleware.ts using plain URL or pathname checks.
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 Astro.locals.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 supplies auth state everywhere, and per-resource checks keep protected pages and endpoints 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