Setting up auth - 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 Blueprint auth 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
Prerequisites​
Blueprint's shared auth module is already wired into Foundation apps as a workspace dependency, so you don't need to install anything. This guide walks through how the pieces fit together and what you need to configure when spinning up (or maintaining) auth in a Foundation-based Next.js App Router app.
If you're wiring auth into a fresh Next.js app inside the monorepo, note that the auth module expects:
next@^14.2.25 || ^15.0.0 || ^16.0.0react@^18.2.0 || ^19.1.0react-dom@^18.2.0 || ^19.1.0graphql-request@^7.2.0(peer dependency)@tanstack/react-query@^5.29.2(peer dependency — required for GraphQL queries in Client Components)@vercel/edge-config@^1.1.0(peer dependency — required for organization-scoped authentication)
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/.
Route Handlers (optional)​
If you use createLoginHandler,
createLogoutHandler, or
createGraphQLHandler
instead of Server Functions, set ALLOWED_REQUEST_ORIGINS (comma-separated) or
validation.allowedRequestOrigins in config with every origin your app is
served from (for example https://localhost:3000 in development). See the
Pages Router trusted origins section.
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.
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.
"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 Blueprint auth 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.
"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