Skip to main content
Two working Next.js apps, both in the repository under others/examples. They do the same thing and differ only in who owns the session.

Plain SDK

Runs the OAuth flow yourself with NemuOAuth. No auth library. Around 12 files, and nothing hidden.

Auth.js

Registers Nemu as a custom Auth.js v5 provider. Auth.js owns the session, the cookies and the refresh.

Which one

Start with the plain SDK example if you are learning the flow. Reach for Auth.js if you already have it, or expect to add Google or GitHub alongside Nemu later. Both run on port 9999, so the redirect URI does not collide with whatever else is on 3000.

Auth.js as a custom provider

Nemu is a plain OAuth 2.0 provider, not OpenID Connect, so it is registered by hand rather than through discovery.
lib/provider.ts
Three of those fields override an Auth.js default that is wrong for a non-OIDC provider.checks defaults to ["pkce"] alone, so state is not sent unless you ask for it.Given authorization as a bare URL string, Auth.js appends scope=openid profile email, even for a type: "oauth" provider. Nemu has no such scopes, so consent fails. Always pass { url, params: { scope } }.userinfo is required even for OAuth 2.0, because Auth.js always fetches a profile. Nemu wraps its response in data, so profile() has to unwrap it or every user gets an undefined id.
The redirect URI Auth.js will use is {origin}/api/auth/callback/{id}, so for id: "nemu" you register http://localhost:9999/api/auth/callback/nemu. That is a different URI from the plain SDK example.

Refreshing with Auth.js

Refresh belongs in the jwt callback, because that is where Auth.js re-issues the session cookie on the same response and the rotated token actually gets saved.
auth.ts
Refresh tokens rotate, and using one revokes the old one immediately. This pattern has a known race, acknowledged in the Auth.js documentation: two concurrent requests on an expired token both refresh, and the loser is left holding a revoked token. Refreshing early shrinks the window. A shared lock or a database session with a single writer closes it.
Two details that cost people an afternoon: account is only present on the first call, at sign in. Every later call has to work from what you already stored on the token. account.expires_at is in seconds, and the SDK’s expires_at is in milliseconds. Convert at the boundary, in both directions.

Type augmentation

Auth.js has no generics for this. Custom session and token fields are declared by augmenting its modules, and the declaration has to live in a file TypeScript already includes.
auth.ts
That import type { JWT } looks unused and is not. TypeScript cannot augment a module it has not resolved, so dropping it turns every field on the token back into unknown without any error pointing at the cause.

Keep the token off the client

The session carries access_token so server code can reach the API. That is only safe because neither example mounts a SessionProvider or calls useSession, so the session is never serialized into a client component. If you add client side session access, take the token out of the session first and read it server side instead.