Skip to content

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}` },
})