payload-authjs: Bridging Payload CMS and Auth.js Without a Seam

November 7, 2025

|repo-review

by Florian Narr

payload-authjs: Bridging Payload CMS and Auth.js Without a Seam

payload-authjs connects Auth.js 5 (NextAuth) and Payload CMS 3 so that an OAuth login through Auth.js also authenticates the user inside Payload — without maintaining two separate user records or writing any sync logic yourself.

Why I starred it

Running Payload CMS alongside Auth.js creates an awkward dual-auth problem. Auth.js handles the OAuth dance, issues a JWT or database session, and controls the browser cookie. Payload has its own auth system with its own JWT, its own user collection, and its own access control layer. Out of the box, they don't know about each other.

The naive approach is to duplicate session handling — check the Auth.js cookie, then separately authenticate against Payload. This plugin collapses that into a single seam by implementing both sides: an Auth.js database adapter that stores Auth.js state inside Payload's database, and a custom Payload auth strategy that reads the Auth.js session to establish Payload identity.

How it works

The plugin registers two integrations on startup.

The Auth.js side: PayloadAdapter

Auth.js's database adapter interface defines a standard set of operations — createUser, getUser, linkAccount, createSession, and so on. packages/payload-authjs/src/authjs/PayloadAdapter.ts implements all of them using Payload's local API. When Auth.js needs to persist a user or a session, it calls into Payload directly via payload.find(), payload.create(), and payload.update(). No second database, no migration — Auth.js state lives in the same Payload collections as everything else.

One detail worth noting: accounts and sessions are stored as array fields on the user document, not as separate collections. So linkAccount looks like this:

// PayloadAdapter.ts
payloadUser = await payload.update({
  collection: userCollectionSlug,
  id: payloadUser.id,
  data: {
    accounts: [
      ...(payloadUser.accounts || []),
      transform.account.fromAdapter(adapterAccount),
    ],
  },
  select: { id: true, accounts: true },
});

That embedding avoids extra collections and joins, but it also means every getUserByAccount call has to query on accounts.provider + accounts.providerAccountId — a dot-notation filter inside a Payload array field. It works, but it won't scale well under heavy write load on the accounts array.

The Payload side: AuthjsAuthStrategy

packages/payload-authjs/src/payload/AuthjsAuthStrategy.ts is the other half. Payload's custom auth strategy interface lets you intercept each authenticated request and decide who the user is. This strategy calls auth() from the Auth.js instance to pull the current session, then does a payload.find() to resolve the full user document. The result gets attached to req.user, making it available to every access control function and endpoint in Payload — as if the user had signed in natively.

// AuthjsAuthStrategy.ts
const { auth } = getAuthjsInstance(payload, collection.slug);
const session = await auth();

const payloadUser = (
  await payload.find({
    collection: collection.slug,
    where: session.user.id
      ? { id: { equals: session.user.id } }
      : { email: { equals: session.user.email } },
    limit: 1,
  })
).docs.at(0);

return {
  user: {
    _strategy: AUTHJS_STRATEGY_NAME,
    collection: collection.slug,
    ...payloadUser,
    ...virtualSessionFields,
  },
};

Instance storage

The Auth.js NextAuthResult is stashed on the Payload instance itself under a non-enumerable __authjs_instances__ property keyed by collection slug (getAuthjsInstance.ts). This is how the strategy retrieves the same Auth.js instance that was configured in the plugin. The Object.defineProperty with enumerable: false keeps it invisible to Payload's own serialization — a pragmatic solution to injecting state without modifying the Payload API surface.

Virtual fields

The plugin also handles virtual fields from JWT sessions. getAllVirtualFields.ts recursively walks the Payload field tree — handling rows, tabs, and nested groups — to collect fields marked virtual: true. On each authenticated request, those field names are extracted from the Auth.js JWT payload and merged onto the user object. This is the mechanism for forwarding things like roles or locale from your OAuth provider into Payload's access control layer without storing them in the database.

Using it

Setup is a three-file change. Create your Auth.js config, get the Auth.js instance via getAuthjsInstance, then register the plugin:

// payload.config.ts
import { authjsPlugin } from "payload-authjs";
import { authConfig } from "./auth.config";

export const config = buildConfig({
  plugins: [
    authjsPlugin({ authjsConfig: authConfig }),
  ],
});
// auth.ts
import payloadConfig from "@payload-config";
import { getPayload } from "payload";
import { getAuthjsInstance } from "payload-authjs";

const payload = await getPayload({ config: payloadConfig });
export const { handlers, signIn, signOut, auth } = getAuthjsInstance(payload);

The plugin auto-generates the users collection with all required fields. You can override it by defining your own users collection — fields are merged, not replaced.

For reading the session in server components, there's a getPayloadSession() helper that returns the full Payload user instead of the Auth.js session shape. On the client there's a usePayloadSession() hook backed by a PayloadSessionProvider.

Rough edges

The big one is in the repo's own README: Auth.js 5 is still in beta, and the Auth.js team announced they're merging into Better Auth. Whether Auth.js 5 ever ships as stable is unclear. This plugin is built on a moving target.

The adapter design — embedding accounts and sessions as array fields on the user document — will cause write contention if multiple sessions are created or updated simultaneously for the same user. Payload doesn't lock documents on update, so concurrent array updates can overwrite each other.

There are no tests in the main package. The packages/payload-authjs/ directory has source files and a dev app but no test suite. For a library handling auth state, that's a meaningful gap.

The multiple auth collections example works, but the implementation relies on the getAuthjsInstance lookup being keyed by collection slug — if you misconfigure slugs across your auth.ts and payload.config.ts, you'll get runtime errors rather than build-time failures.

Bottom line

If you're building a Payload CMS 3 app and want OAuth without writing your own session bridge, this plugin removes the boilerplate cleanly. Hold off if you need production-grade stability — Auth.js 5's future is uncertain, and the absence of tests means you're accepting some unknown surface area.

CrawlerCode/payload-authjs on GitHub
CrawlerCode/payload-authjs