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

OptionTypeDescription
publicKeystringEnvironment public key (default: ASCENDKIT_ENV_KEY env var)
secretKeystringEnvironment secret key (default: ASCENDKIT_SECRET_KEY env var)
authSecretstringBetter Auth session secret (default: BETTER_AUTH_SECRET, then AUTH_SECRET, then ASCENDKIT_SECRET_KEY)
apiUrlstringAscendKit API URL (default: ASCENDKIT_API_URL or https://api.ascendkit.dev)
appBaseUrlstringPublic app origin used for auth callbacks and redirects
allowedHostsstring[]Optional allowlist for dynamic callback host resolution
trustedProxyHeadersbooleanTrust x-forwarded-* headers when deriving the request origin
waitlistRedirectPathstringRedirect path for waitlisted users after social login (e.g. "/waitlist")
rejectedRedirectPathstringRedirect 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?

ScenarioRecommended option
Marketing site or landing page — user clicks "Sign in" in the header and stays on the pageModal<SignInButton /> / <SignUpButton /> (default mode="modal")
Dedicated /login and /signup routes with the same styling everywhereDedicated page (all-in-one)<AscendKitAuthCard />
Fully custom layouts or marketing content next to a login formDedicated page (split)<Login /> and <SignUp />
App with a custom nav button that should open auth without navigatingHookuseAuthModal()
Gate protected content until the user signs inWrap 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 using SignInButton / 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

PropTypeDescription
onSuccess() => voidCalled after success
onError(error: string) => voidCalled on failure
callbackURLstringRedirect 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" />
PropTypeDefaultDescription
mode"modal" \</td><td className="px-3 py-2 text-muted-foreground">"redirect""modal"Open modal or redirect
redirectUrlstring"/auth/sign-in" or "/auth/sign-up"Path for redirect mode
asChildbooleanfalseDelegate rendering to child element
childrenReactNode"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();
ReturnTypeDescription
userUser or nullCurrent user
isLoadedbooleanAuth state resolved
isAuthenticatedbooleanSigned in
signOut() => Promise<void>Sign out

useAuthModal

const { isOpen, view, open, close } = useAuthModal();
ReturnTypeDescription
isOpenbooleanModal open
view"sign-in" or "sign-up"Current view
open(view?) => voidOpen modal
close() => voidClose 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();
ReturnTypeDescription
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):

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

EventWhat the user sees
Clicks verification linkRedirected 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.):

EventWhat the user sees
Social login successSigned 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:

EventWhat the user sees
Magic link submitBanner: "Check your email for the sign-in link."
Clicks magic linkUser 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

SignalWhereType
authNotificationuseAscendKitContext(){ variant, message } \</td><td className="px-3 py-2 text-muted-foreground">null
clearAuthNotificationuseAscendKitContext()() => void
?verified=trueURL query paramAuto-detected on mount
?waitlisted=trueURL query paramAuto-detected on mount (with ?verified=true)
user.emailVerifieduseAscendKit()boolean
user.waitlistStatususeAscendKit()"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

  1. Tailwind class-based (preferred): If your app sets class="dark" on <html>, all AscendKit components switch to dark mode.
  2. System preference fallback: If no Tailwind dark class is detected, components follow the user's OS prefers-color-scheme setting.

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:

VariableLight defaultDark default
--ak-modal-bg#fff#0a0a0a
--ak-modal-color#1a1a1a#f5f5f5
--ak-modal-muted#888#888
--ak-modal-hover-bgrgba(0,0,0,0.05)rgba(255,255,255,0.1)

Notification banners:

VariableLight defaultDark default
--ak-notif-success-bg#f0fdf4rgba(34,197,94,0.1)
--ak-notif-success-color#166534#86efac
--ak-notif-success-border#bbf7d0rgba(34,197,94,0.2)
--ak-notif-error-bg#fef2f2rgba(239,68,68,0.1)
--ak-notif-error-color#991b1b#fca5a5
--ak-notif-error-border#fecacargba(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() and createAuthRouteHandlers() — these require Next.js for server-side session management and OAuth callbacks
  • Server-side analytics (Analytics class) — 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.