Add authentication and analytics to your Next.js app.
Next.js Integration
This guide covers the @ascendkit/nextjs SDK with Next.js 14+ (App Router). For Python backends, see Python SDK. For other React frameworks, see Other React Frameworks at the bottom of this page.
Auth setup
After installing the SDK and initializing the CLI, follow these four steps:
1. Create the auth runtime
// lib/auth.ts
import { createAscendKitAuthRuntime } from "@ascendkit/nextjs/server";
export const authRuntime = createAscendKitAuthRuntime();
The runtime reads ASCENDKIT_ENV_KEY, ASCENDKIT_SECRET_KEY, and ASCENDKIT_API_URL from your environment automatically. We recommend setting APP_URL in your env file to your app's origin (e.g., http://localhost:3000 for local dev) — the SDK can infer it from requests, but an explicit value avoids "Invalid origin" errors with social login. If you prefer explicit configuration:
export const authRuntime = createAscendKitAuthRuntime({
publicKey: process.env.ASCENDKIT_ENV_KEY!,
});
Options
| Option | Type | Description |
|---|---|---|
publicKey | string | Environment public key (default: ASCENDKIT_ENV_KEY env var) |
secretKey | string | Environment secret key (default: ASCENDKIT_SECRET_KEY env var) |
authSecret | string | Better Auth session secret (default: BETTER_AUTH_SECRET, then AUTH_SECRET, then ASCENDKIT_SECRET_KEY) |
apiUrl | string | AscendKit API URL (default: ASCENDKIT_API_URL or https://api.ascendkit.dev) |
appBaseUrl | string | Public app origin used for auth callbacks and redirects |
allowedHosts | string[] | Optional allowlist for dynamic callback host resolution |
trustedProxyHeaders | boolean | Trust x-forwarded-* headers when deriving the request origin |
waitlistRedirectPath | string | Redirect path for waitlisted users after social login (e.g. "/waitlist") |
rejectedRedirectPath | string | Redirect path for rejected users after social login (e.g. "/rejected") |
Example with waitlist redirect:
export const authRuntime = createAscendKitAuthRuntime({
waitlistRedirectPath: "/waitlist",
rejectedRedirectPath: "/rejected",
});
If your app has a fixed public URL, configure it through AscendKit:
export const authRuntime = createAscendKitAuthRuntime({
appBaseUrl: process.env.APP_URL,
});
APP_URL should be the origin where your app is running — http://localhost:3000 for local development, or https://app.yourdomain.com for production. You can leave it blank and let the SDK infer the origin from the request, but an explicit value is more reliable.
If you use preview deployments, allow dynamic hosts explicitly:
export const authRuntime = createAscendKitAuthRuntime({
allowedHosts: ["localhost:3000", "*.vercel.app", "app.example.com"],
});
When a user signs up via social login (Google, GitHub, etc.) and lands on the waitlist, they are redirected to waitlistRedirectPath instead of receiving a ?error=waitlist_pending query param. The same applies to rejected users. If these options are not set, the default behavior appends ?error=waitlist_pending or ?error=waitlist_rejected to the callback URL.
2. Add the API route
// app/api/auth/[...all]/route.ts
import { authRuntime } from "@/lib/auth";
import { createAuthRouteHandlers } from "@ascendkit/nextjs/server";
export const { GET, POST } = createAuthRouteHandlers(authRuntime);
3. Add the provider
// app/layout.tsx
import { AscendKitProvider } from "@ascendkit/nextjs";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AscendKitProvider>
{children}
</AscendKitProvider>
</body>
</html>
);
}
The provider reads NEXT_PUBLIC_ASCENDKIT_ENV_KEY and NEXT_PUBLIC_ASCENDKIT_API_URL from the environment. If you prefer explicit configuration:
<AscendKitProvider publicKey={process.env.NEXT_PUBLIC_ASCENDKIT_ENV_KEY!}>
4. Add sign-in and sign-up
<AscendKitProvider> already mounts a global auth modal for you — you don't need to render one yourself. You have three UX patterns to choose from. Pick the one that fits your app; you can mix them.
Which option should I use?
| Scenario | Recommended option |
|---|---|
| Marketing site or landing page — user clicks "Sign in" in the header and stays on the page | Modal — <SignInButton /> / <SignUpButton /> (default mode="modal") |
Dedicated /login and /signup routes with the same styling everywhere | Dedicated page (all-in-one) — <AscendKitAuthCard /> |
| Fully custom layouts or marketing content next to a login form | Dedicated page (split) — <Login /> and <SignUp /> |
| App with a custom nav button that should open auth without navigating | Hook — useAuthModal() |
| Gate protected content until the user signs in | Wrap with <SignedIn> / <SignedOut> and trigger the modal from the fallback |
Option A — Modal (no auth page needed)
Drop buttons anywhere in your layout. AscendKitProvider handles everything else.
// app/layout.tsx (inside the provider)
import { SignInButton, SignUpButton, SignedIn, SignedOut, AscendKitUserButton } from "@ascendkit/nextjs";
<header>
<SignedOut>
<SignInButton />
<SignUpButton />
</SignedOut>
<SignedIn>
<AscendKitUserButton />
</SignedIn>
</header>
Want to use your own button component? Pass asChild:
import { Button } from "@/components/ui/button";
<SignInButton asChild>
<Button variant="ghost">Log in</Button>
</SignInButton>
Need to open the modal programmatically (e.g. after a pricing CTA or analytics event)? Use the hook:
import { useAuthModal } from "@ascendkit/nextjs";
function UpgradeCta() {
const { open } = useAuthModal();
return <button onClick={() => open("sign-up")}>Get started</button>;
}
Option B — Dedicated page with AscendKitAuthCard
Use this when you want a real /login route (e.g. for SEO, email deep links, or password managers). One component handles sign-in, sign-up, and forgot-password views.
// app/login/page.tsx
import { AscendKitAuthCard } from "@ascendkit/nextjs";
export default function AuthPage() {
return <AscendKitAuthCard />;
}
Pin a specific view if you have separate routes for sign-in and sign-up:
// app/signup/page.tsx
<AscendKitAuthCard view="SIGN_UP" />
Views: "SIGN_IN", "SIGN_UP", "FORGOT_PASSWORD".
Option C — Separate Login / SignUp components
Use this when you want full control of each page (e.g. marketing copy beside the form, different layouts for sign-in vs sign-up).
// app/login/page.tsx
import { Login } from "@ascendkit/nextjs";
export default function LoginPage() {
return (
<Login
onSuccess={() => { window.location.href = "/dashboard"; }}
onSwitchToSignUp={() => { window.location.href = "/signup"; }}
onForgotPassword={() => { window.location.href = "/forgot-password"; }}
/>
);
}
// app/signup/page.tsx
import { SignUp } from "@ascendkit/nextjs";
export default function SignUpPage() {
return (
<SignUp
onSuccess={() => { window.location.href = "/dashboard"; }}
onSwitchToLogin={() => { window.location.href = "/login"; }}
/>
);
}
Heads up: you don't need
<AscendKitAuthCard />,<Login />, or<SignUp />if you're only usingSignInButton/SignUpButton/useAuthModal()— the provider's built-in modal already renders the forms. Pick one pattern per route and stick with it.
Components
All components render inside <AscendKitProvider>. Social buttons appear automatically when providers are enabled via the CLI or portal.
Login / SignUp
| Prop | Type | Description |
|---|---|---|
onSuccess | () => void | Called after success |
onError | (error: string) => void | Called on failure |
callbackURL | string | Redirect after social auth |
onSwitchToSignUp | () => void | (Login) Show "Sign Up" link in footer |
onSwitchToLogin | () => void | (SignUp) Show "Sign In" link in footer |
onForgotPassword | () => void | (Login) Show "Forgot password?" link |
onBackToLogin | () => void | (ForgotPassword) Show "Back to sign in" link |
AscendKitAuthCard
Single component for all auth views. Use instead of separate Login/SignUp pages.
import { AscendKitAuthCard } from "@ascendkit/nextjs";
<AscendKitAuthCard />
<AscendKitAuthCard view="SIGN_UP" />
Views: "SIGN_IN", "SIGN_UP", "FORGOT_PASSWORD"
SocialButton
import { SocialButton } from "@ascendkit/nextjs";
<SocialButton provider="google" />
<SocialButton provider="github" label="Sign in with GitHub" />
AscendKitUserButton
User avatar with sign-out dropdown for your app header or sidebar.
import { AscendKitUserButton } from "@ascendkit/nextjs";
<AscendKitUserButton />
Top nav:
<AscendKitUserButton layout="top-nav" />
Expanded vertical nav:
<AscendKitUserButton layout="vertical-nav" />
Collapsed vertical nav:
<AscendKitUserButton collapsed />
SignInButton / SignUpButton
Convenience buttons that open the auth modal or redirect to an auth page.
import { SignInButton, SignUpButton } from "@ascendkit/nextjs";
<SignInButton />
<SignUpButton mode="redirect" redirectUrl="/auth/sign-up" />
| Prop | Type | Default | Description |
|---|---|---|---|
mode | "modal" \</td><td className="px-3 py-2 text-muted-foreground">"redirect" | "modal" | Open modal or redirect |
redirectUrl | string | "/auth/sign-in" or "/auth/sign-up" | Path for redirect mode |
asChild | boolean | false | Delegate rendering to child element |
children | ReactNode | "Sign in" / "Sign up" | Button label |
Using asChild with your own components
When asChild is true, the button delegates its onClick handler to the child element instead of wrapping in its own <button>. This avoids nested <button> elements when using UI libraries like shadcn/ui:
import { SignInButton } from "@ascendkit/nextjs";
import { Button } from "@/components/ui/button";
<SignInButton asChild>
<Button variant="ghost">Log in</Button>
</SignInButton>
Using the useAuthModal hook directly
For full control (e.g. combining analytics with auth), use the hook instead:
import { useAuthModal } from "@ascendkit/nextjs";
import { Button } from "@/components/ui/button";
function NavAuth() {
const { open } = useAuthModal();
return (
<Button onClick={() => open("sign-in")}>Log in</Button>
);
}
SignedIn / SignedOut / AuthLoading
import { SignedIn, SignedOut, AuthLoading } from "@ascendkit/nextjs";
<AuthLoading><p>Loading...</p></AuthLoading>
<SignedIn><AscendKitUserButton /></SignedIn>
<SignedOut><a href="/login">Sign in</a></SignedOut>
Hooks
useAscendKit
const { user, isLoaded, isAuthenticated, signOut } = useAscendKit();
| Return | Type | Description |
|---|---|---|
user | User or null | Current user |
isLoaded | boolean | Auth state resolved |
isAuthenticated | boolean | Signed in |
signOut | () => Promise<void> | Sign out |
useAuthModal
const { isOpen, view, open, close } = useAuthModal();
| Return | Type | Description |
|---|---|---|
isOpen | boolean | Modal open |
view | "sign-in" or "sign-up" | Current view |
open | (view?) => void | Open modal |
close | () => void | Close modal |
Access Tokens (frontend → backend)
If your app has a separate backend (Python, Node.js, etc.), use access tokens to authenticate API calls from the browser. AscendKit issues short-lived RS256 JWTs that any backend can verify using JWKS — no shared secrets needed.
1. Add the access token route
// app/api/access-token/route.ts
import { authRuntime } from "@/lib/auth";
import { createAccessTokenHandler } from "@ascendkit/nextjs/server";
export const GET = createAccessTokenHandler({ authRuntime });
This exchanges the user's session cookie for a short-lived access token. The route must be in the same Next.js app as your auth runtime.
2. Get tokens from React
import { useAccessToken } from "@ascendkit/nextjs";
function Dashboard() {
const { getAccessToken } = useAccessToken();
async function fetchData() {
const token = await getAccessToken();
const res = await fetch("https://api.example.com/data", {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
return <button onClick={fetchData}>Load data</button>;
}
getAccessToken() caches the token in memory and automatically refreshes it when it nears expiry (within 5 minutes). Concurrent calls are deduplicated — only one network request is made.
3. Verify on your backend
The access token is a standard RS256 JWT. Verify it using AscendKit's JWKS endpoint with any JWT library, or use the Python SDK:
from ascendkit import AccessTokenVerifier
verifier = AccessTokenVerifier()
claims = await verifier.verify_async(token)
print(claims["sub"]) # usr_...
print(claims["email"])
See Python SDK for FastAPI, Flask, and Django integration examples.
useAccessToken
const { getAccessToken } = useAccessToken();
const token = await getAccessToken();
| Return | Type | Description |
|---|---|---|
getAccessToken | () => Promise<string> | Returns a cached or fresh access token |
Throws if no authenticated session exists. Pass a custom path if your route isn't at /api/access-token:
const { getAccessToken } = useAccessToken("/api/custom-token-path");
Auth Lifecycle
AscendKit handles email verification and waitlist flows automatically. The SDK shows in-modal notifications for each state transition — no extra pages or code required.
Default behavior (zero-config)
Credentials (email/password):
| Event | What the user sees |
|---|---|
| Signup (verification enabled) | Success banner: "Account created! Please check your email to verify your account." |
| Signup (waitlist enabled) | Success banner: "You are on the waitlist. We will notify you when your account is approved." |
| Signup (rejected) | Error banner: "Your account application was not approved." |
| Login (email not verified) | Error banner: "Email not verified" |
| Login (waitlisted) | Error banner: "Your account is pending approval." |
| Login (rejected) | Error banner: "Your account application was not approved." |
Email verification redirect:
| Event | What the user sees |
|---|---|
| Clicks verification link | Redirected to your app with modal open + banner: "Email verified! You can now sign in." |
| Clicks verification link (waitlist enabled) | Redirected to your app with info banner: "Email verified! Your account is pending approval. We'll notify you when it's ready." Sign-in modal does not open. |
Social login (Google, GitHub, etc.):
| Event | What the user sees |
|---|---|
| Social login success | Signed in and redirected to callbackURL |
Social login (waitlisted, waitlistRedirectPath set) | Redirected to waitlistRedirectPath (e.g. /waitlist) |
| Social login (waitlisted, no redirect path) | Redirected to callbackURL with ?error=waitlist_pending |
Social login (rejected, rejectedRedirectPath set) | Redirected to rejectedRedirectPath (e.g. /rejected) |
| Social login (rejected, no redirect path) | Redirected to callbackURL with ?error=waitlist_rejected |
Magic link:
| Event | What the user sees |
|---|---|
| Magic link submit | Banner: "Check your email for the sign-in link." |
| Clicks magic link | User is signed in directly — no verification or password required. |
All credential and magic link flows work identically whether the user is signing in through the provider-managed modal (SignInButton / useAuthModal), <AscendKitAuthCard />, or the split <Login /> / <SignUp /> components. Social login redirects are handled server-side by the OAuth proxy plugin — see auth runtime options for waitlistRedirectPath and rejectedRedirectPath.
Custom override: notification handling
Read authNotification from context to render messages in your own UI:
import { useAscendKitContext } from "@ascendkit/nextjs";
function MyCustomAuthPage() {
const { authNotification, clearAuthNotification } = useAscendKitContext();
// { variant: "success" | "error" | ..., message: string } or null
if (authNotification) {
return (
<div className={authNotification.variant === "error" ? "error" : "success"}>
<p>{authNotification.message}</p>
<button onClick={clearAuthNotification}>Dismiss</button>
</div>
);
}
return <YourSignInForm />;
}
Custom override: email verification landing page
After clicking the verification link, the user is redirected with ?verified=true. If waitlist is also enabled, the URL includes ?verified=true&waitlisted=true. The SDK auto-detects these params and shows the appropriate banner.
To handle it yourself instead, create a page that reads the query params:
// app/verified/page.tsx
"use client";
import { useSearchParams } from "next/navigation";
import { useAuthModal } from "@ascendkit/nextjs";
export default function VerifiedPage() {
const params = useSearchParams();
const { open } = useAuthModal();
const verified = params.get("verified") === "true";
const waitlisted = params.get("waitlisted") === "true";
if (verified && waitlisted) {
return (
<div>
<h1>Email verified!</h1>
<p>Your account is pending approval. We'll notify you when it's ready.</p>
</div>
);
}
if (verified) {
return (
<div>
<h1>Email verified!</h1>
<p>Your email has been confirmed.</p>
<button onClick={() => open("sign-in")}>Sign in</button>
</div>
);
}
return null;
}
Custom override: waitlist gating
After login, check user.waitlistStatus to gate access:
import { useAscendKit } from "@ascendkit/nextjs";
function Dashboard() {
const { user, isLoaded } = useAscendKit();
if (!isLoaded) return <p>Loading...</p>;
if (user?.waitlistStatus === "pending") {
return (
<div>
<h1>You're on the waitlist</h1>
<p>We'll notify you when your account is approved.</p>
</div>
);
}
if (user?.waitlistStatus === "rejected") {
return <p>Your application was not approved.</p>;
}
return <YourDashboard />;
}
Signals reference
| Signal | Where | Type |
|---|---|---|
authNotification | useAscendKitContext() | { variant, message } \</td><td className="px-3 py-2 text-muted-foreground">null |
clearAuthNotification | useAscendKitContext() | () => void |
?verified=true | URL query param | Auto-detected on mount |
?waitlisted=true | URL query param | Auto-detected on mount (with ?verified=true) |
user.emailVerified | useAscendKit() | boolean |
user.waitlistStatus | useAscendKit() | "pending" \</td><td className="px-3 py-2 text-muted-foreground">"approved" \</td><td className="px-3 py-2 text-muted-foreground">"rejected" \</td><td className="px-3 py-2 text-muted-foreground">undefined |
Theming & Dark Mode
AscendKit auth UI adapts to dark mode automatically with no configuration.
How it works
- Tailwind class-based (preferred): If your app sets
class="dark"on<html>, all AscendKit components switch to dark mode. - System preference fallback: If no Tailwind dark class is detected, components follow the user's OS
prefers-color-schemesetting.
Both modes work simultaneously. Tailwind class takes priority when present.
CSS custom properties
Override these variables on any ancestor element to match your brand.
Modal:
| Variable | Light default | Dark default |
|---|---|---|
--ak-modal-bg | #fff | #0a0a0a |
--ak-modal-color | #1a1a1a | #f5f5f5 |
--ak-modal-muted | #888 | #888 |
--ak-modal-hover-bg | rgba(0,0,0,0.05) | rgba(255,255,255,0.1) |
Notification banners:
| Variable | Light default | Dark default |
|---|---|---|
--ak-notif-success-bg | #f0fdf4 | rgba(34,197,94,0.1) |
--ak-notif-success-color | #166534 | #86efac |
--ak-notif-success-border | #bbf7d0 | rgba(34,197,94,0.2) |
--ak-notif-error-bg | #fef2f2 | rgba(239,68,68,0.1) |
--ak-notif-error-color | #991b1b | #fca5a5 |
--ak-notif-error-border | #fecaca | rgba(239,68,68,0.2) |
Example: custom brand colors
:root {
--ak-modal-bg: #fafaf9;
--ak-notif-success-bg: #ecfdf5;
--ak-notif-success-color: #065f46;
}
html.dark {
--ak-modal-bg: #111111;
--ak-notif-success-bg: rgba(16, 185, 129, 0.1);
--ak-notif-success-color: #6ee7b7;
}
Auth form styling
The auth forms (sign-in, sign-up, forgot password) are rendered by Better Auth UI. They inherit your app's Tailwind theme and can be styled via its theming API. AscendKit components use ak- prefixed CSS classes so they never conflict with your existing styles.
Analytics
Client-side (React)
Track user events from the browser. Browser context (userAgent, locale, screen size, referrer, URL) is captured automatically. Requires the user to be authenticated.
import { useAnalytics } from "@ascendkit/nextjs";
function CheckoutButton() {
const { track, flush } = useAnalytics();
return (
<button
onClick={async () => {
track("checkout.completed", { total: 99.99 });
await flush();
}}
>
Buy
</button>
);
}
Events batch in memory and flush every 30 seconds, when the batch fills (10 events), or on page hide.
flush() returns a promise, so await flush() when you need the queued events sent before navigation or other follow-up work.
Server-side (Node.js)
Track events from your backend with trusted identity (secret key auth).
import { Analytics } from "@ascendkit/nextjs/server";
const analytics = new Analytics();
analytics.track("usr_456", "checkout.completed", { total: 99.99 });
The constructor reads ASCENDKIT_SECRET_KEY and ASCENDKIT_ENV_KEY from your environment. If either is missing, it throws at initialization.
Events batch in memory and flush every 30 seconds, when the batch fills (10 events), or on shutdown(). Call analytics.shutdown() for graceful cleanup (e.g. in process.on("beforeExit")).
See Analytics for the full guide including event naming, Python server-side tracking, and how analytics powers journey automations.
Other React Frameworks
The React hooks and components from @ascendkit/nextjs work in any React 18+ framework (Vite, Remix, Astro, etc.):
<AscendKitProvider>,useAscendKit(),useAuthModal(),useAnalytics()— all work<Login>,<SignUp>,<AscendKitAuthCard>,<SocialButton>— all work<SignedIn>,<SignedOut>,<AuthLoading>— all work
What does not work outside Next.js:
createAscendKitAuthRuntime()andcreateAuthRouteHandlers()— these require Next.js for server-side session management and OAuth callbacks- Server-side analytics (
Analyticsclass) — requires a Node.js server, which you can use with any framework that has a server
For non-Next.js React apps, you need to proxy auth requests to a separate backend that handles the AscendKit auth runtime, or use AscendKit's hosted auth endpoints directly.