Better Auth in a Turborepo monorepo: Next.js client, Express API, Bun workspaces

Run Better Auth in a Turborepo monorepo with a Next.js app and an Express API: where betterAuth() lives, shared contracts, env vars, cookies and Docker.

MC
Martin Coll

In a Turborepo monorepo with a Next.js app and an Express API, put the betterAuth() instance in the API (it needs your database and secrets), keep the Next.js app as a thin client that talks to it with a baseURL, and share only types and Zod schemas through an internal workspace package. Three things trip people up: Turborepo's strict env mode silently drops variables you did not declare, the two apps are separate origins so cookies and CORS need explicit configuration, and internal packages that ship TypeScript must be transpiled by Next.js. This guide uses the real layout of useDeploy and is checked against [email protected], [email protected] and Bun 1.3.11.

The layout

The root package.json declares the workspaces and the package manager:

{
  "workspaces": ["apps/*", "packages/*", "tests"],
  "packageManager": "[email protected]",
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "typecheck": "turbo run typecheck",
    "test": "turbo run test"
  }
}

That matches the Turborepo guidance: applications in apps/, everything else in packages/, a lockfile, no nested globs. With Bun, the workspaces field is all the configuration there is. In useDeploy:

apps/server      Express + Bun, Prisma, Better Auth
apps/client      Next.js
packages/contracts   Zod schemas shared by both (auth, billing, ...)
packages/shared      Result type, errors, permission catalog
packages/tsconfig    shared tsconfig bases
packages/eslint-config

Packages depend on each other with the workspace:* protocol:

"dependencies": {
  "@app/contracts": "workspace:*",
  "@app/shared": "workspace:*"
}

Where betterAuth() lives

Put it in the API app, not in a shared package that the Next.js app also imports. The instance takes a Prisma client, a secret and email-sending callbacks. Pulling those into the browser bundle, or into a package both apps import, is how a server secret ends up on the wrong side.

useDeploy builds it in apps/server/src/modules/iam/infrastructure/auth/better-auth.adapter.ts (an adapter behind an IAuthProvider port) and mounts it in Express before the body parser, as the Express integration docs require:

router.all('/api/auth/*', normalizeClientIp, toNodeHandler(container.cradle.betterAuthInstance));

The Next.js app never imports that file. It has two ways to talk to auth:

  • Its own HTTP client for the REST surface (/api/v1/auth/*), using types generated from the server's OpenAPI document (bun run generate:api).
  • A Better Auth client for the native surface (apps/client/lib/auth-client.ts):
import { createAuthClient } from 'better-auth/client';
import { passkeyClient } from '@better-auth/passkey/client';

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3005';

export const authClient = createAuthClient({
  baseURL: API_URL,
  plugins: [passkeyClient()],
});

baseURL points at the API, not at the Next.js origin. Both apps declare better-auth and @better-auth/passkey at the same range (^1.6.9), and the install links them to one physical copy (node_modules/.bun/[email protected]...). Keep the ranges aligned: if the client and server resolve different versions, the plugin types and the wire format can drift apart.

Share contracts, not the auth instance

The client forms and the server validators must agree on the shape of a sign-in or a 2FA code. Put those Zod schemas in the internal package and import them on both sides:

// packages/contracts/src/auth.ts
export const MagicLinkSendInput = z.object({ email: z.string().email() });
// apps/server: validateRequest({ body: authContracts.MagicLinkSendInput })
// apps/client: the same schema drives the form

An internal package can export raw TypeScript, as useDeploy's do ("exports": { ".": "./src/index.ts" }), so there is no build step for it. The price is that Next.js has to compile it. The transpilePackages docs describe the option, and useDeploy's next.config.ts sets:

transpilePackages: ['@app/contracts', '@app/shared', '@better-auth/passkey'],

The last entry is a Better Auth specific workaround, documented in a comment in that file: @better-auth/passkey ships an exports map with a dev-source condition pointing at TypeScript files that are not in the published tarball, and Turbopack picks that condition first and fails. Transpiling the package bypasses the exports map. The client build also runs next build --webpack rather than Turbopack.

turbo.json

Abridged from the repo's turbo.json:

{
  "globalDependencies": ["**/.env", "**/.env.local"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] },
    "dev": { "cache": false, "persistent": true, "dependsOn": ["^build"] },
    "typecheck": { "dependsOn": ["^build"], "outputs": [] },
    "test": { "dependsOn": ["^build"], "passThroughEnv": ["REQUIRE_DB_TESTS"], "outputs": ["coverage/**"] }
  }
}

^build means "build my dependencies first". The internal packages here have no build script, so for them it is a no-op, and that is the point of exporting source. dev is persistent and uncached, so bun run dev starts the Next.js dev server (port 3004) and the API with bun --hot (port 3005) together.

The env trap

Turborepo runs tasks in Strict Mode: only variables declared in env, globalEnv or the passthrough keys reach the task. We checked this with turbo 2.9.7 in a throwaway workspace, running a script that echoes three variables:

FOO=bar BAR=baz NEXT_PUBLIC_X=pub turbo run show
# FOO=[] NEXT_PUBLIC_X=[pub] BAR=[baz]   (BAR is declared in passThroughEnv)

FOO was dropped, NEXT_PUBLIC_X came through (Turborepo infers that prefix), and BAR came through because it was in passThroughEnv. So BETTER_AUTH_SECRET=... bun run dev from your shell will not reach the API. Two ways to avoid the surprise:

  • Put the values in apps/server/.env. Turborepo does not load .env files itself, but the app's runtime does: Bun and Next.js read the file from the package directory the task runs in. useDeploy's .env.example files live in each app.
  • For variables that do come from the shell or CI, list them in globalEnv (they then also change the cache key) or passThroughEnv (they do not).

globalDependencies: ["**/.env", ...] is the other half: it makes a change to a .env file invalidate the cache for every task, so a stale build with an old NEXT_PUBLIC_API_URL is not restored.

Auth across two origins

The Next.js app and the API are separate deployables with separate origins, so three settings have to line up. In useDeploy they all derive from environment variables:

SettingWhereValue
baseURLbetterAuth({ baseURL })BETTER_AUTH_URL, the public URL of the API
trustedOriginsbetterAuth({ ... })CORS_ORIGINS, the web app origin(s)
CORScors({ origin: env.CORS_ORIGINS, credentials: true })same list
Client baseURLcreateAuthClientNEXT_PUBLIC_API_URL, inlined at build time

In production the adapter sets advanced.useSecureCookies, sameSite: 'none', secure and partitioned cookie attributes plus crossSubDomainCookies: { enabled: true }, because the browser has to send the session cookie from the web origin to the API origin. Better Auth's cookie docs note that Safari's tracking prevention can block cookies between different domains, and recommend either proxying API requests through the frontend's domain or using a shared parent domain with cross-subdomain cookies. The simplest setup is app.example.com for the web app and api.example.com for the API.

With crossSubDomainCookies enabled and no domain option, Better Auth uses the hostname of baseURL for the cookie domain (1.6.9, cookies/index.mjs), so check what BETTER_AUTH_URL resolves to rather than assuming a parent domain.

Docker in a monorepo

The Dockerfiles copy every workspace's package.json and the whole packages/ folder before running bun install --frozen-lockfile, so the workspace:* links resolve. One more rule specific to this repo: the root postinstall runs prisma generate, which needs apps/server/prisma/schema.prisma, so the schema is copied before the install as well. The build context is the repository root, for example docker build -f apps/server/Dockerfile ..

Frequently asked questions

Should I put Better Auth config in a shared packages/auth?

Only if it holds nothing server-only. A package imported by both apps must not pull in Prisma or secrets. Sharing the Zod schemas and types is the safe part.

Why does my API not see the variables I exported before turbo run dev?

Strict Mode. Declare them in env, globalEnv or passThroughEnv, or load them from the app's own .env file.

Do I need transpilePackages for every internal package?

For packages that export TypeScript source and are imported by the Next.js app, add them to the list. The Next.js docs say workspace packages are transpiled automatically in many setups, so check your version before assuming; listing them explicitly is harmless.

Can the Next.js app and the API share one domain?

Yes, and it removes the cross-origin cookie questions. Route /api/* to the API with a reverse proxy.

Which tool runs scripts, Turborepo or Bun?

Both. turbo run orchestrates and caches tasks across packages; bun --filter runs a script in one package, as the root seed and generate:api scripts do.


useDeploy ships this layout with Better Auth, shared contracts and the Docker builds already working. See the Better Auth integration and the installation docs.