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:
- A working login/logout flow with Server Actions
- 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, make sure you upgrade them before proceeding.
Installation
Install the @krakentech/blueprint-auth package and its peer dependencies.
The @krakentech/blueprint-auth package relies on:
@krakentech/blueprint-api@^3.3.0,@tanstack/react-query@^5.29.2(optional: required to enable client functions),graphql-request@^7.2.0,@vercel/edge-config@^1.1.0(optional: required to enable organization-scoped authentication).
- pnpm
- npm
- yarn
- bun
pnpm add @krakentech/blueprint-auth @krakentech/blueprint-api graphql-request
npm install @krakentech/blueprint-auth @krakentech/blueprint-api graphql-request
yarn add @krakentech/blueprint-auth @krakentech/blueprint-api graphql-request
bun add @krakentech/blueprint-auth @krakentech/blueprint-api 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:
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({
appRoutes: {
dashboard: { pathname: "/dashboard" },
login: { pathname: "/login" },
},
});
The appRoutes configuration defines the protected dashboard route and login
page. These paths must align with your actual route structure in app/.
Middleware
Create a Next.js
middleware
(version ≤15) or
proxy
(version ≥16) using the
createAuthMiddleware factory.
- 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: ["/dashboard/:path*", "/login"],
};
The matcher array determines which routes the middleware protects. See Next.js middleware matcher docs for advanced patterns.
import { createAuthMiddleware } from "@krakentech/blueprint-auth/middleware";
import { authConfig } from "@/lib/auth/config";
export const proxy = createAuthMiddleware(authConfig);
export const config = {
matcher: ["/dashboard/:path*", "/login"],
};
The matcher array determines which routes the proxy protects. See Next.js proxy matcher docs for advanced patterns.
Server Functions
Create the Server Functions required to handle authentication using the
createAppRouterAuth factory.
"use server";
import { createAppRouterAuth } from "@krakentech/blueprint-auth/server";
import { cookies, headers } from "next/headers";
import { authConfig } from "./config";
export const { login, logout, getSession, getUserScopedGraphQLClient } =
createAppRouterAuth(authConfig, { cookies, headers });
Handling Redirect Errors
The @krakentech/blueprint-auth package uses Next.js's redirect() function
for navigation in Server Actions like login() and logout(). When
redirect() is called, it throws an internal error that Next.js catches to
perform the navigation.
You must re-throw the redirect error in your Server Actions, otherwise the redirect won't happen.
- Next.js 15+
- Next.js 14
Use the unstable_rethrow() function from next/navigation:
import { unstable_rethrow } from "next/navigation";
try {
await login({ input: { email, password } });
} catch (error) {
unstable_rethrow(error); // Re-throws ALL Next.js internal errors
// Handle your application errors below
console.error("Login failed:", error);
}
Despite the unstable_ prefix, unstable_rethrow() is the official Next.js API
for handling control flow errors in Server Actions and is safe to use in
production.
Use isRedirectError() from Next.js internals:
import { isRedirectError } from "next/dist/client/components/redirect-error";
try {
await login({ input: { email, password } });
} catch (error) {
if (isRedirectError(error)) throw error;
// Handle your application errors below
console.error("Login failed:", error);
}
This uses an internal Next.js function from next/dist. While not part of the
public API, this pattern has been stable since Next.js 13 and remains available
in Next.js 16, making it the recommended approach for Next.js 14 until
unstable_rethrow() becomes available.
Recipes
This section provides examples of how to use the authentication hooks in your application.
Login form
Create a login form using the login Server Function.
- Next.js 15+
- Next.js 14
"use server";
import { unstable_rethrow } from "next/navigation";
import { login } from "@/lib/auth/server";
type LoginActionState = { error?: string };
export async function loginAction(
_prevState: LoginActionState,
formData: FormData,
): Promise<LoginActionState> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
try {
return await login({ input: { email, password } });
} catch (error) {
unstable_rethrow(error); // Re-throws Next.js internal errors
return { error: "Login failed. Please check your credentials." };
}
}
"use server";
import { isRedirectError } from "next/dist/client/components/redirect-error";
import { login } from "@/lib/auth/server";
type LoginActionState = { error?: string };
export async function loginAction(
_prevState: LoginActionState,
formData: FormData,
): Promise<LoginActionState> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
try {
return await login({ input: { email, password } });
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
return { error: "Login failed. Please check your credentials." };
}
}
"use client";
import { useActionState } from "react";
import { loginAction } from "@/actions/login";
export function LoginForm() {
const [state, action, pending] = useActionState(loginAction, {});
return (
<form action={action}>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
</div>
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={pending}>
{pending ? "Logging in..." : "Login"}
</button>
</form>
);
}
import { LoginForm } from "@/components/LoginForm";
export default function LoginPage() {
return <LoginForm />;
}
For advanced form patterns including validation, error handling, and
accessibility best practices, see the
Building Forms tutorial. You can also
use form libraries like Conform,
Formik, or
React Hook Form.
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, pathname }) {
const validLocale = hasLocale(routing.locales, locale)
? locale
: routing.defaultLocale;
return getPathname({ locale: validLocale, href: pathname });
},
},
// ... 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.
"use client";
import { Button, Tooltip } from "@radix-ui/themes";
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { type PropsWithChildren, useActionState } from "react";
export type ActionButtonState = {
error?: string;
} | null;
type ActionButtonProps = PropsWithChildren<{
action(
state: ActionButtonState,
formData: FormData,
): Promise<ActionButtonState>;
initialState?: ActionButtonState;
}>;
export function ActionButton({
action,
children,
initialState,
}: ActionButtonProps) {
const [state, dispatch, pending] = useActionState(action, initialState);
return (
<form action={dispatch}>
<Button loading={pending} type="submit">
{children}
{state?.error && (
<Tooltip content={state.error}>
<ExclamationTriangleIcon style={{ pointerEvents: "none" }} />
</Tooltip>
)}
</Button>
</form>
);
}
import { logout } from "@/lib/auth/server";
import { ActionButton } from "./ActionButton";
export function LogoutButton({ nextPage }: { nextPage?: string | null }) {
return (
<ActionButton action={logout.bind(null, { nextPage })} initialState={null}>
Logout
</ActionButton>
);
}
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.
"use server";
import { cache } from "react";
import { getUserScopedGraphQLClient } 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 () => {
const graphQLClient = getUserScopedGraphQLClient();
const { viewer } = await graphQLClient.request(UserQuery);
return viewer;
});
import { Avatar, Skeleton } from "@radix-ui/themes";
import { AvatarIcon } from "@radix-ui/react-icons";
import { getUser } from "@/queries/getUser";
export async function UserAvatar() {
const user = await getUser();
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
"use server";
import { cache } from "react";
import * as z from "zod";
import { getUserScopedGraphQLClient } from "@/lib/auth/server";
import { graphql, type Scalar, type VariablesOf } from "@/lib/graphql";
import { getPaginatedNodes } from "@/utils/getPaginatedNodes";
export 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 billSchema = z.object({
id: z.string(),
amount: z.number(),
issuedDate: z.string(),
billType: z.enum<Scalar<"BillTypeEnum">>([
"STATEMENT",
"INVOICE",
"CREDIT_NOTE",
"PRE_KRAKEN",
"COLLECTIVE",
]),
});
export const getAccountBills = cache(
async (variables: AccountBillsVariables) => {
const graphQLClient = getUserScopedGraphQLClient();
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 "./getAccountBills";
type AccountBillsInput = Omit<AccountBillsVariables, "after">;
export function accountBillsQueryOptions(variables: AccountBillsInput) {
return infiniteQueryOptions({
queryKey: ["account", variables.accountNumber, "bills", variables.first],
queryFn: ({ pageParam }) =>
getAccountBills({ ...variables, after: pageParam }),
getNextPageParam: (lastPage) => {
if (!lastPage?.pageInfo.hasNextPage) {
return null;
}
return lastPage.pageInfo.endCursor;
},
initialPageParam: 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 = 10 }: 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>
);
}
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
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;
}
}
import { Suspense } from "react";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/utils/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