Next.js
The browser SDK in an App Router application — a lazy client, a callback route, and token verification in a route handler.
There is no Next.js package, and this page is why there does not need to be:
@kleora-io/browser is framework-free, and the whole sign-in happens in
the browser. What follows is the App Router; the Pages Router differs only in
where the two files live.
npm install @kleora-io/browser
1. One client, created in the browser
Next.js evaluates a client component on the server too, so the instance is
built lazily and only where a browser exists. Reaching for location at module
scope is the usual way this breaks during a build.
// lib/kleora.ts
import { createKleora, type KleoraClient } from '@kleora-io/browser'
let client: KleoraClient | null = null
export function getKleora(): KleoraClient {
if (!client) {
client = createKleora({
issuer: process.env.NEXT_PUBLIC_KLEORA_ISSUER!,
clientId: process.env.NEXT_PUBLIC_KLEORA_CLIENT_ID!,
redirectUri: window.location.origin + '/callback',
storage: 'session',
})
}
return client
}
Call getKleora() from an effect or an event handler — never while
rendering.
2. The callback route
app/callback/page.tsx, matching the redirectUri above and the
http://localhost:3000/callback you registered on the client:
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { getKleora } from '@/lib/kleora'
export default function Callback() {
const router = useRouter()
useEffect(() => {
getKleora()
.handleRedirectCallback()
.then(({ appState }) => {
const returnTo = (appState as { returnTo?: string })?.returnTo
router.replace(returnTo ?? '/')
})
.catch(() => router.replace('/'))
}, [router])
return <p>Signing you in…</p>
}
3. A hook the rest of the application can use
'use client'
import { useEffect, useState } from 'react'
import { getKleora } from '@/lib/kleora'
import type { User } from '@kleora-io/browser'
export function useUser() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const kleora = getKleora()
const unsubscribe = kleora.onAuthChange((state) => setUser(state.user))
kleora
.checkSession()
.then(() => setUser(kleora.getUser()))
.finally(() => setLoading(false))
return unsubscribe
}, [])
return { user, loading }
}
Sending somebody to sign in is then one call, from an event handler or from an effect on a page that requires it:
await getKleora().loginWithRedirect({ returnTo: window.location.pathname })
4. Verify the token in a route handler
Your route handlers are an API like any other, and they verify the access token
offline against the issuer's published key set. @kleora-io/node declares
Node 20+, so run the route on the Node runtime rather than the edge one.
// app/api/invoices/route.ts
import { verifyAccessToken, TokenError } from '@kleora-io/node'
export const runtime = 'nodejs'
export async function GET(request: Request) {
const header = request.headers.get('authorization') ?? ''
if (!header.startsWith('Bearer ')) {
return Response.json({ detail: 'Unauthorized' }, { status: 401 })
}
try {
const claims = await verifyAccessToken(header.slice(7), {
issuer: process.env.KLEORA_ISSUER!,
audience: process.env.KLEORA_AUDIENCE!,
})
if (!claims.permissions.includes('invoices:read')) {
return Response.json({ detail: 'Forbidden' }, { status: 403 })
}
return Response.json({ tenant: claims.tenant })
} catch (error) {
if (error instanceof TokenError && error.code === 'issuer_unavailable') {
return Response.json({ detail: 'Issuer unavailable' }, { status: 503 })
}
return Response.json({ detail: 'Unauthorized' }, { status: 401 })
}
}
verifyAccessToken takes the bare token string, which is what a Request's
Headers object gives you. authenticate() — the other entry point — expects
an object with a headers.authorization property, which is Express's shape
rather than the Fetch API's.
audience is your environment's api_audience if you have set one, and the
client id otherwise. The key set is fetched once and cached for as long as its
Cache-Control says; an unreachable issuer is issuer_unavailable, which is a
503 from you rather than a 401 that tells every caller its token is bad.
Sending the token is the client's half:
const token = await getKleora().getAccessToken()
const response = await fetch('/api/invoices', {
headers: { Authorization: `Bearer ${token}` },
})