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.
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-configPackages 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 formAn 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.envfiles 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.examplefiles 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) orpassThroughEnv(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:
| Setting | Where | Value |
|---|---|---|
baseURL | betterAuth({ baseURL }) | BETTER_AUTH_URL, the public URL of the API |
trustedOrigins | betterAuth({ ... }) | CORS_ORIGINS, the web app origin(s) |
| CORS | cors({ origin: env.CORS_ORIGINS, credentials: true }) | same list |
Client baseURL | createAuthClient | NEXT_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.