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
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.
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
paramsandsearchParamsobjects. The Next.js 14cookies()andheaders()functions also return their values synchronously. - In the React Canary releases used by Next.js 14, import
useFormStatefromreact-dominstead ofuseActionStatefromreact. React renamed the hook, moved it to thereactpackage, and added its pending result in React 19. See React PR #28491. - Use
useFormStatusfromreact-domwhen the form needs a pending state. - Pass an identity
CacheFunctiontocreateAppRouterAuthif the installed React version does not exportcache. 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. ImportisRedirectErrorfromnext/dist/client/components/redirect. In eachcatchblock, rethrow the error whenisRedirectError(error)returnstrue. 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.
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
- npm
- yarn
- bun
pnpm add @krakentech/blueprint-auth @vercel/global-config graphql-request
npm install @krakentech/blueprint-auth @vercel/global-config graphql-request
yarn add @krakentech/blueprint-auth @vercel/global-config graphql-request
bun add @krakentech/blueprint-auth @vercel/global-config graphql-request
If you plan to use GraphQL queries in Client Components (see
recipe below), you'll also need to
install @tanstack/react-query:
- pnpm
- npm
- yarn
- bun
pnpm add @tanstack/react-query
npm install @tanstack/react-query
yarn add @tanstack/react-query
bun add @tanstack/react-query
React Query is not required if you only use Server Components, Server Actions, and the basic auth flow.
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:
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"
When deploying to Vercel, environment variables should be configured in the
Vercel dashboard, not committed
to .env files.
The use of environment variables is strongly recommended for supported options.
Configuration object​
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" },
},
});
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.
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.
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)​
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.
- Next.js ≤15
- Next.js 16+
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|.*\\..*).*)"],
};
import { createAuthMiddleware } from "@krakentech/blueprint-auth/middleware";
import { authConfig } from "@/lib/auth/config";
export const proxy = createAuthMiddleware(authConfig);
export const config = {
matcher: ["/", "/((?!api|_next|_vercel|.*\\..*).*)"],
};
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.
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​
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
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:
- Kraken OAuth is recommended for new integrations.
- Email & password: App Router covers the login Server Action, form, redirects, and error handling.
Supporting Internationalization (i18n)​
If your application supports multiple languages with localized URLs, configure the auth package to use your i18n library's pathname translation.
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​
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()andlogout()redirect to localized destinations - Error Handling: Redirects to localized login pages on auth errors
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.
"use server";
import { logout } from "@/lib/auth/server";
export async function logoutAction() {
await logout();
}
Use React 19 useFormStatus to show the pending state.
"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​
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>
);
}
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​
The examples below use gql.tada for type-safe
GraphQL queries. The graphql function provides full TypeScript inference for
query results and variables.
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;
});
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>
);
}
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.
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.
"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>
);
}
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>
);
}
"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,
};
}
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));
}
"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>;
}
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.
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