Skip to main content

Getting started: App Router

This guide walks you through the steps to enable Kraken authentication in your Next.js app. When you're done, you'll know how the different parts of the package work together in order to provide a seamless authentication experience for your users.

What You'll Build​

By the end of this guide, you'll have:

  • Server-side auth functions for your chosen login flow
  • Protected routes using Next.js middleware
  • Session management with server-side authentication
  • Authenticated GraphQL queries in Server and Client Components
Learn more

This guide only covers the basic setup of the @krakentech/blueprint-auth package, check out our authentication guides to implement additional features.

Functions mentioned in this guide have more options, please refer to the API reference for more information. Functions are also annotated with JSDoc, providing in-editor documentation.

Requirements​

The @krakentech/blueprint-auth package requires:

  • next@^14.2.25 || ^15.0.0 || ^16.0.0,
  • react@^18.2.0 || ^19.1.0,
  • react-dom@^18.2.0 || ^19.1.0

If your project uses unsupported versions, upgrade them before you proceed.

Example versions

Most examples in this guide use Next.js 15 and React 19 syntax. The Partial Prefetching example requires Next.js 16.3.

Using Next.js 14 and React 18

The App Router became stable in Next.js 13.4. This package supports Next.js 14.2.25 or later and React 18.2.0 or later.

  • Use synchronous params and searchParams objects. The Next.js 14 cookies() and headers() functions also return their values synchronously.
  • In the React Canary releases used by Next.js 14, import useFormState from react-dom instead of useActionState from react. React renamed the hook, moved it to the react package, and added its pending result in React 19. See React PR #28491.
  • Use useFormStatus from react-dom when the form needs a pending state.
  • Pass an identity CacheFunction to createAppRouterAuth if the installed React version does not export cache. The auth functions still work, but repeated calls are not deduplicated during the render.
  • Do not use unstable_rethrow, which is unavailable in Next.js 14. Import isRedirectError from next/dist/client/components/redirect. In each catch block, rethrow the error when isRedirectError(error) returns true. Then handle other errors.

Use this identity function when React does not export cache:

type CacheFunction = <CachedFunction extends Function>(
fn: CachedFunction,
) => CachedFunction;

const noCache: CacheFunction = (fn) => fn;

Pass noCache as the cache property when you call createAppRouterAuth.

Installation​

Install the @krakentech/blueprint-auth package and its peer dependencies.

Peer dependencies

The @krakentech/blueprint-auth package relies on:

  • @tanstack/react-query@^5.29.2 (optional: required to enable client functions),
  • graphql-request@^7.2.0,
  • @vercel/global-config@^1.5.0 (optional: required when you use the supplied shared cache adapter).
pnpm add @krakentech/blueprint-auth @vercel/global-config graphql-request
When to install React Query

If you plan to use GraphQL queries in Client Components (see recipe below), you'll also need to install @tanstack/react-query:

pnpm add @tanstack/react-query

React Query is not required if you only use Server Components, Server Actions, and the basic auth flow.

Private packages

Packages of the @krakentech NPM organization are published privately to the NPM registry. To install these packages, you need to configure your package manager to use a Kraken issued NPM access token. Please get in touch if you need one.

Configuration​

Use the createAuthConfig factory to create a centralized configuration object that can be reused throughout your application. Check out the API Reference to learn more about the available configuration options.

Environment variables​

Create a .env.local file in the root of your project and define the following environment variables:

.env.local
GLOBAL_CONFIG="Vercel Global Config connection string"
VERCEL_AUTH_TOKEN="Vercel API token with Global Config write access"
VERCEL_TEAM_ID="Vercel team ID"
KRAKEN_AUTH_ENDPOINT="https://auth.xxxx-kraken.systems/"
KRAKEN_ACCESS_TOKEN_ISSUERS="https://api.xxxx-kraken.systems/v1/graphql/,https://auth.xxxx-kraken.systems/token/,https://support.xxxx-kraken.systems"
KRAKEN_GRAPHQL_ENDPOINT="Kraken GraphQL endpoint URL"
KRAKEN_X_CLIENT_IP_SECRET_KEY="Client IP secret key"
Vercel Deployment

When deploying to Vercel, environment variables should be configured in the Vercel dashboard, not committed to .env files.

Environment variables

The use of environment variables is strongly recommended for supported options.

Configuration object​

@/lib/auth/config.ts
import { createAuthConfig } from "@krakentech/blueprint-auth";

export const authConfig = createAuthConfig({
apiRoutes: {
graphql: { kraken: "/api/graphql/kraken" },
login: "/api/auth/login",
logout: "/api/auth/logout",
session: "/api/auth/session",
},
appRoutes: {
dashboard: { pathname: "/dashboard" },
home: { pathname: "/" },
login: { pathname: "/login" },
},
});
Token verification

KRAKEN_ACCESS_TOKEN_ISSUERS is a comma-separated list. An issuer is the iss field in an access token. Use the exact issuers for your Kraken environment. Matching is case-sensitive. A trailing slash changes the issuer. For OAuth, add token/ to the exact Kraken auth endpoint. Replace the example domains above with the values for your Kraken environment.

KRAKEN_AUTH_ENDPOINT must use HTTPS. For local development, HTTP is accepted only for localhost, 127.0.0.1, and [::1]. Blueprint Auth gets the public JSON Web Key Set (JWKS) from .well-known/jwks.json at this endpoint. See Session management for an overview of the session lifecycle.

Route Configuration

The appRoutes configuration defines the dashboard, home, and login routes. The auth package redirects to the home route after logout. These paths must match the route structure in app/.

Server cache​

For organization auth, create one server-only cache adapter. This guide uses the supplied Vercel Global Config adapter.

For user and viewer auth, sessions, login, logout, and OAuth, skip this step. Omit the cacheAdapter import and property in the factory examples below, but keep cache, cookies, and headers. You can also omit @vercel/global-config and the GLOBAL_CONFIG, VERCEL_AUTH_TOKEN, and VERCEL_TEAM_ID variables. Calling the factory's getAuth.org without an adapter fails with AuthMissingPropertiesError for cacheAdapter.

lib/auth/cache.ts
import "server-only";

import { createGlobalConfigCacheAdapter } from "@krakentech/blueprint-auth/cache/global-config";

export const cacheAdapter = createGlobalConfigCacheAdapter();

The adapter stores organization tokens. It checks that its required environment values are present. It also parses the store ID and creates the Global Config client when this module loads. The cache provider checks the Vercel settings and permissions during a read or write.

Route Handlers (optional)​

validation.allowedRequestOrigins

If you use createLoginHandler, createLogoutHandler, or createGraphQLHandler, set ALLOWED_REQUEST_ORIGINS or validation.allowedRequestOrigins. Add every origin that serves your app. Use only the scheme, host, and optional port. For example, use http://localhost:3000 during local development.

Each cookie-authenticated POST request must include an Origin header or a Referer header. The handler rejects a request that has neither header. When a request has both headers, both origins must be in the list. See the Pages Router trusted request origins section.

Middleware​

Create a Next.js middleware (version ≤15) or proxy (version ≥16) using the createAuthMiddleware factory.

The examples use the recommended catch-all matcher.

middleware.ts
import { createAuthMiddleware } from "@krakentech/blueprint-auth/middleware";
import { authConfig } from "@/lib/auth/config";

export const middleware = createAuthMiddleware(authConfig);

export const config = {
matcher: ["/", "/((?!api|_next|_vercel|.*\\..*).*)"],
};
Post-auth middleware mutations

If you modify headers after authMiddleware has run, wrap the final response with forwardHeaders to ensure they are visible in Server Components via headers() from next/headers.

Server Functions​

Create the Server Functions required to handle authentication using the createAppRouterAuth factory.

lib/auth/server.ts
import { createAppRouterAuth } from "@krakentech/blueprint-auth/server";
import { cookies, headers } from "next/headers";
import { cache } from "react";
import { cacheAdapter } from "./cache";
import { authConfig } from "./config";

export const {
getAuth,
getGraphQLClient,
getSession,
login,
logout,
redirectToLogin,
} = createAppRouterAuth(authConfig, {
cache,
cacheAdapter,
cookies,
headers,
});

Pass React's cache function to createAppRouterAuth. React can reuse server results during one render.

Partial Prefetching​

Authentication reads

Next.js 16.3 applications that enable Partial Prefetching should wrap authentication reads with "use cache: private". First, register the auth profile with createAuthCacheProfiles.

Configure private authentication reads
lib/auth/server.ts
import { createAppRouterAuth } from "@krakentech/blueprint-auth/server";
import { cacheLife } from "next/cache";
import { cookies, headers } from "next/headers";
import { cache } from "react";

import { cacheAdapter } from "./cache";
import { authConfig } from "./config";

const auth = createAppRouterAuth(authConfig, {
cache,
cacheAdapter,
cookies,
headers,
});

async function getOrgAuth() {
"use cache: private";
cacheLife("auth");

return auth.getAuth.org();
}

async function getUserAuth() {
"use cache: private";
cacheLife("auth");

return auth.getAuth.user();
}

async function getViewerAuth() {
"use cache: private";
cacheLife("auth");

return auth.getAuth.viewer();
}

export const getAuth = {
org: getOrgAuth,
user: getUserAuth,
viewer: getViewerAuth,
};

export async function getSession() {
"use cache: private";
cacheLife("auth");

return auth.getSession();
}

export const {
generateKrakenOAuthURI,
getGraphQLClient,
getRequestPathname,
login,
logout,
prefetchSession,
redirectToLogin,
} = auth;

The auth profile lets the browser reuse rendered authentication UI for up to five minutes. New server renders and Server Actions verify authentication again.

Handling Redirect Errors​

Auth Server Actions such as login and logout use Next.js redirects. If you catch errors around these calls, invoke unstable_rethrow(error) from next/navigation before handling application errors. Otherwise, a successful redirect can be swallowed. See the email-and-password login action for a complete example.

Recipes​

Use the configured auth functions in your application's flows.

Login form​

Choose the authentication method your Kraken environment supports:

Supporting Internationalization (i18n)​

If your application supports multiple languages with localized URLs, configure the auth package to use your i18n library's pathname translation.

When You Need This

You need i18n configuration when you use translated route segments, not just locale prefixes. For example, if /dashboard becomes /fr/tableau-de-bord in French, configure i18n to ensure auth redirects use the translated paths.

Configuration with next-intl​

lib/auth/config.ts
import { createAuthConfig } from "@krakentech/blueprint-auth";
import { getPathname } from "@/i18n/navigation";
import { hasLocale } from "next-intl";
import { routing } from "@/i18n/routing";

export const authConfig = createAuthConfig({
appRoutes: {/* ... */},
i18n: {
localeCookie: "NEXT_LOCALE",
getLocalizedPathname({ locale, url, ...href }) {
const requestedLocale = locale ?? url.pathname.split("/")[1];
return getPathname({
href,
locale: hasLocale(routing.locales, requestedLocale)
? requestedLocale
: routing.defaultLocale,
});
},
},
// ... other config
});

See the next-intl documentation for setup instructions.

How It Works​

When configured:

  • Middleware: Matches routes using localized pathnames
  • Server Functions: login() and logout() redirect to localized destinations
  • Error Handling: Redirects to localized login pages on auth errors
Comprehensive Guide

For detailed examples including URL-based locale detection, simple path prefix patterns, and integration patterns, see the i18n guide.

Logout button​

Create a logout button using the logout Server Function.

Create a Server Action for logout.

actions/logout.ts
"use server";

import { logout } from "@/lib/auth/server";

export async function logoutAction() {
await logout();
}

Use React 19 useFormStatus to show the pending state.

components/LogoutButton.tsx
"use client";

import { useFormStatus } from "react-dom";
import { logoutAction } from "@/actions/logout";

function LogoutSubmitButton() {
const { pending } = useFormStatus();

return (
<button type="submit" disabled={pending}>
{pending ? "Logging out..." : "Logout"}
</button>
);
}

export function LogoutButton() {
return (
<form action={logoutAction}>
<LogoutSubmitButton />
</form>
);
}

Using session data​

components/UserMenu.tsx
import { Avatar, Button, DropdownMenu } from "@radix-ui/themes";
import { AvatarIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { LogoutButton } from "@/components/LogoutButton";
import { getSession } from "@/lib/auth/server";

export async function UserMenu() {
const session = await getSession();

if (!session.isAuthenticated) {
return (
<Button asChild>
<Link href="/login">Login</Link>
</Button>
);
}

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Avatar fallback={<AvatarIcon />} />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item asChild>
<Link href="/dashboard/settings">Settings</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<LogoutButton />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}
components/AppHeader.tsx
import { Skeleton } from "@radix-ui/themes";
import { Suspense } from "react";
import { Logo } from "@/components/Logo";
import { NavigationMenu } from "@/components/NavigationMenu";
import { UserMenu } from "@/components/UserMenu";

export function AppHeader() {
return (
<header>
<NavigationMenu />
<Logo />
<Suspense fallback={<Skeleton />}>
<UserMenu />
</Suspense>
</header>
);
}

GraphQL queries in Server Components​

gql.tada

The examples below use gql.tada for type-safe GraphQL queries. The graphql function provides full TypeScript inference for query results and variables.

queries/getUser.ts
import type { AuthContext } from "@krakentech/blueprint-auth/server";
import { cache } from "react";
import { getGraphQLClient } from "@/lib/auth/server";
import { graphql } from "@/lib/graphql";

const UserQuery = graphql(`
query User {
viewer {
dateOfBirth
email
familyName
fullName
givenName
mobile
preferredName
title
}
}
`);

export const getUser = cache(async (auth: AuthContext.User) => {
const graphQLClient = getGraphQLClient.user({ auth });
const { viewer } = await graphQLClient.request(UserQuery);
return viewer;
});
components/UserAvatar.tsx
import { Avatar, Skeleton } from "@radix-ui/themes";
import { AvatarIcon } from "@radix-ui/react-icons";
import { getAuth } from "@/lib/auth/server";
import { getUser } from "@/queries/getUser";

export async function UserAvatar() {
const auth = await getAuth.user();
if (!auth) return <Avatar fallback={<AvatarIcon />} />;

const user = await getUser(auth);
const name = user.preferredName ?? user.givenName;

return <Avatar fallback={name?.[0]?.toUpperCase() ?? <AvatarIcon />} />;
}

export function UserAvatarSkeleton() {
return (
<Skeleton>
<Avatar fallback={<AvatarIcon />} />
</Skeleton>
);
}
components/UserMenu.tsx
import { Avatar, Button, DropdownMenu } from "@radix-ui/themes";
import { AvatarIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Suspense } from "react";
import { UserAvatar, UserAvatarSkeleton } from "@/components/UserAvatar";
import { LogoutButton } from "@/components/LogoutButton";
import { getSession } from "@/lib/auth/server";

export async function UserMenu() {
const session = await getSession();

if (!session.isAuthenticated) {
return (
<Button asChild>
<Link href="/login">Login</Link>
</Button>
);
}

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Suspense fallback={<UserAvatarSkeleton />}>
<UserAvatar />
</Suspense>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item asChild>
<Link href="/dashboard/settings">Settings</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<LogoutButton />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}

GraphQL queries in Client Components​

Create a shared React Query client helper. On the server, each call creates a new client so cached data is not shared across requests. In the browser, reuse one client so rerenders and initial Suspense retries do not discard the cache.

lib/react-query/getQueryClient.ts
import {
isServer,
QueryClient,
defaultShouldDehydrateQuery,
} from "@tanstack/react-query";

function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
shouldRedactErrors: () => false,
},
},
});
}

let browserQueryClient: QueryClient | undefined = undefined;

export function getQueryClient() {
if (isServer) {
return makeQueryClient();
} else {
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}

Use this helper in a Client Component provider and mount it in your app layout above any components that use React Query, including hydration boundaries. Keep the layout as a Server Component.

lib/react-query/QueryProvider.tsx
"use client";

import { QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { getQueryClient } from "./getQueryClient";

export function QueryProvider({ children }: { children: ReactNode }) {
const queryClient = getQueryClient();

return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
app/layout.tsx
import type { ReactNode } from "react";
import { QueryProvider } from "@/lib/react-query/QueryProvider";

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}
actions/getAccountBills.ts
"use server";

import { BlueprintAuthErrorCode } from "@krakentech/blueprint-auth";
import * as z from "zod";
import { getAuth, getGraphQLClient, redirectToLogin } from "@/lib/auth/server";
import { graphql, type Scalar, type VariablesOf } from "@/lib/graphql";
import { getPaginatedNodes } from "@/utils/getPaginatedNodes";

const AccountBillsQuery = graphql(`
query AccountBills($accountNumber: String!, $after: String, $first: Int) {
account(accountNumber: $accountNumber) {
bills(first: $first, after: $after) {
edges {
node {
id
amount
issuedDate
billType
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`);

export type AccountBillsVariables = VariablesOf<typeof AccountBillsQuery>;

const billTypes = [
"STATEMENT",
"INVOICE",
"CREDIT_NOTE",
"PRE_KRAKEN",
"COLLECTIVE",
] as const satisfies readonly Scalar<"BillTypeEnum">[];

const billSchema = z.object({
id: z.string(),
amount: z.number(),
issuedDate: z.string(),
billType: z.enum(billTypes),
});

export async function getAccountBills(variables: AccountBillsVariables) {
const auth = await getAuth.user();
if (!auth) {
return redirectToLogin({
errorCode: BlueprintAuthErrorCode.AuthenticationRequired,
});
}

const graphQLClient = getGraphQLClient.user({ auth });
const { account } = await graphQLClient.request(AccountBillsQuery, variables);

if (!account) {
throw new Error(`Account ${variables.accountNumber} not found.`);
}

if (!account.bills) {
throw new Error(
`Unable to fetch bills for account ${variables.accountNumber}.`,
);
}

return {
bills: getPaginatedNodes(account.bills, billSchema),
pageInfo: account.bills.pageInfo,
};
}
queries/useAccountBills.ts
import {
infiniteQueryOptions,
useSuspenseInfiniteQuery,
} from "@tanstack/react-query";
import {
getAccountBills,
type AccountBillsVariables,
} from "@/actions/getAccountBills";

type AccountBillsInput = Omit<AccountBillsVariables, "after">;

export function accountBillsQueryOptions(variables: AccountBillsInput) {
const first = variables.first ?? 10;

return infiniteQueryOptions({
queryKey: ["account", variables.accountNumber, "bills", first],
queryFn: ({ pageParam }) =>
getAccountBills({ ...variables, first, after: pageParam }),
getNextPageParam: (lastPage) => {
if (!lastPage?.pageInfo.hasNextPage) {
return null;
}
return lastPage.pageInfo.endCursor;
},
initialPageParam: null as string | null,
select: (data) => data.pages.flatMap((page) => page.bills),
});
}

export function useAccountBills(variables: AccountBillsInput) {
return useSuspenseInfiniteQuery(accountBillsQueryOptions(variables));
}
components/AccountBills.tsx
"use client";

import { Button } from "@radix-ui/themes";
import { BillCard } from "@/components/BillCard";
import { useAccountBills } from "@/queries/useAccountBills";

interface AccountBillsProps {
accountNumber: string;
first?: number;
}

export function AccountBills({ accountNumber, first }: AccountBillsProps) {
const {
data: bills,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useAccountBills({
accountNumber,
first,
});

return (
<div>
{bills.map((bill) => (
<BillCard bill={bill} key={bill.id} />
))}
{hasNextPage && (
<Button loading={isFetchingNextPage} onClick={() => fetchNextPage()}>
Load more
</Button>
)}
</div>
);
}

export function AccountBillsSkeleton() {
return <p role="status">Loading bills...</p>;
}
Prefetching with React Query

Queries made with @tanstack/react-query can be prefetched on the server. Check out @tanstack/react-query's documentation to learn how to enable prefetching.

Learn more

Reuse the getQueryClient helper and app-level QueryProvider above. The shared accountBillsQueryOptions helper defaults first to 10 for both the query key and request, so server prefetching and the client use the same cache entry.

app/dashboard/accounts/[accountNumber]/bills/page.tsx
import { Suspense } from "react";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/react-query/getQueryClient";
import { AccountBills, AccountBillsSkeleton } from "@/components/AccountBills";
import { accountBillsQueryOptions } from "@/queries/useAccountBills";

async function BillsPageContent({
paramsPromise,
}: {
paramsPromise: Promise<{ accountNumber: string }>;
}) {
const { accountNumber } = await paramsPromise;
const queryClient = getQueryClient();

queryClient.prefetchInfiniteQuery(
accountBillsQueryOptions({ accountNumber }),
);

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<AccountBills accountNumber={accountNumber} />
</HydrationBoundary>
);
}

export default function Page({
params,
}: PageProps<"/dashboard/accounts/[accountNumber]/bills">) {
return (
<>
<h1>Bills</h1>
<Suspense fallback={<AccountBillsSkeleton />}>
<BillsPageContent paramsPromise={params} />
</Suspense>
</>
);
}

Next Steps​

Now that you have authentication set up, explore these guides to enhance your implementation:

  • Kraken OAuth: Enable OAuth-based authentication flows
  • Anonymous Access: Allow pre-signed key access for unauthenticated users
  • Masquerade: Implement staff impersonation for support scenarios
  • Organization-Scoped Auth: Restrict authentication to specific organizations
  • Building Forms: Learn advanced form patterns including validation, error handling, and accessibility best practices