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:

  • 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
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, make sure you upgrade them before proceeding.

Installation

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

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 add @krakentech/blueprint-auth @krakentech/blueprint-api 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
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({
appRoutes: {
dashboard: { pathname: "/dashboard" },
login: { pathname: "/login" },
},
});
Route Configuration

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.

middleware.ts
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"],
};
Customizing matchers

The matcher array determines which routes the middleware protects. See Next.js middleware matcher docs for advanced patterns.

Server Functions

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

lib/auth/server.ts
"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.

Re-throw redirect errors

You must re-throw the redirect error in your Server Actions, otherwise the redirect won't happen.

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);
}
Safe to use

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.

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.

actions/login.ts
"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." };
}
}
components/LoginForm.tsx
"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>
);
}
app/login/page.tsx
import { LoginForm } from "@/components/LoginForm";

export default function LoginPage() {
return <LoginForm />;
}
Advanced form patterns

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.

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, 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() 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.

components/ActionButton.tsx
"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>
);
}
components/LogoutButton.tsx
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

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
"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;
});
components/UserAvatar.tsx
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>
);
}
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

queries/getAccountBills.ts
"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,
};
},
);
queries/useAccountBills.ts
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));
}
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 = 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>
);
}
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
utils/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;
}
}
app/dashboard/accounts/[accountNumber]/bills/page.tsx
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