TL;DR
users-permissions plugin works fine, but if you want modern auth flows (social providers, two-factor, magic links) the community Better Auth plugins are a solid alternative — though they're still in alpha/beta.plugin-better-auth refuses to load alongside users-permissions, so you also lose the role/permission system U&P provides. You get it back by adding plugin-api-permissions.plugin-better-auth (auth flows), plugin-api-permissions (Content API RBAC), plugin-better-auth-dashboard (admin UI for users and sessions).Strapi LaunchPad is the marketing-site starter we use to show off what Strapi v5 + Next.js can do. Out of the box it uses the official @strapi/plugin-users-permissions plugin for auth, which is the safe, supported choice.
But Strapi's community has been building a more modern alternative: a set of plugins that wraps the excellent Better Auth library and gives you sign-up flows, sessions, social providers, two-factor, magic links, and a real admin dashboard for managing users — all without writing controller code.
In this tutorial I'm going to walk you through, end to end, replacing users-permissions with the Better Auth stack on top of LaunchPad. You'll come out the other side with:
plugin-better-auth + plugin-api-permissions + plugin-better-auth-dashboardHeads-up before you start: all three community plugins are pre-release at the time of writing —
plugin-better-auth@1.0.0-beta.6,plugin-api-permissions@1.0.0-alpha.3, andplugin-better-auth-dashboard@1.0.0-alpha.7. Do not run this in production yet. This is a playground / starter-template exercise.
Pick the path that matches what you actually want:
1. Just clone the finished example — fastest, no learning.
git clone https://github.com/PaulBratslavsky/launchpad-better-auth-example.git
cd launchpad-better-auth-example
yarn install
yarn setup
yarn seed
yarn devThat gives you a working Better Auth stack at http://localhost:3000 (Next.js) with Strapi running on http://localhost:1337. Skip the rest of this post.
2. Apply automatically to your own project — when you already have a Strapi v5 + Next.js App Router project and don't want to do this by hand.
There's a Claude Code skill at .claude/skills/better-auth-setup/ in the example repo that automates every change in this post. Clone the example repo into a directory next to your own project (or copy the .claude/skills/better-auth-setup/ folder into your own project), open Claude Code, and ask "set up better auth on this strapi and next.js project". The skill discovers your Strapi and Next.js folders and applies the same templates this post walks through.
3. Walk through it by hand — recommended if you're seeing these plugins for the first time, your project differs from LaunchPad in any meaningful way, or you want to understand each gotcha so you can debug later. That's the rest of the post.
The three paths share the same end state, the same templates, and the same gotcha fixes. Reading the manual walkthrough below makes the skill's output easier to audit, and the cloned example a useful diff target.
users-permissions is one plugin that does three jobs at once: it authenticates users, it provides a User content type, and it authorizes API requests via roles and permissions. The Better Auth stack splits those concerns:
The trade-off: more plugins to install, but each has one job. You can swap plugin-better-auth out for any other auth provider later (Clerk, Auth0, Supabase) without rewriting your permission model, because plugin-api-permissions is auth-agnostic.
Before you start, make sure you have:
Start from the official main branch so we share a baseline:
git clone https://github.com/strapi/LaunchPad.git
cd LaunchPadLaunchPad is a monorepo-ish structure with two top-level folders:
1LaunchPad/
2├── strapi/ # Strapi v5 backend
3├── next/ # Next.js 16 frontend
4└── ...Install, seed, and run the project from the repo root to confirm everything works on your machine:
yarn install
yarn setup
yarn seed
yarn devWhat each script does:
yarn install — installs the root workspace deps.yarn setup — runs yarn install inside strapi/ and next/ and copies each app's .env.example to .env.yarn seed — imports the bundled Strapi data so Next.js has content to render. Without this, the home page throws Failed to fetch single type "global" because it queries the global single type at boot against an empty DB.yarn dev — boots Strapi on http://localhost:1337 and Next.js on http://localhost:3000 concurrently.Open http://localhost:3000 — you should see the LaunchPad marketing site. Open http://localhost:1337/admin and confirm Strapi boots (you can skip creating the admin user — we'll wipe the database in Step 11). Then stop both servers and create a branch to do the migration on:
git checkout -b better-auth-migrationplugin-better-auth enforces a minimum Strapi version of 5.45.0 in its register lifecycle. Open strapi/package.json and check that @strapi/strapi and @strapi/plugin-cloud are at 5.45.0 or higher.
At time of writing LaunchPad's main ships with 5.46.0, so most likely you don't need to change anything. If you cloned an older snapshot, bump them to a matching 5.45+ version:
1{
2 "dependencies": {
3 "@strapi/plugin-cloud": "5.46.0",
4 "@strapi/strapi": "5.46.0"
5 }
6}@strapi/plugin-users-permissionsplugin-better-auth replaces users-permissions — it doesn't run alongside it. The plugin checks for the users-permissions package at boot and throws if it finds it, so disabling it in config/plugins.ts isn't enough; you have to remove it from package.json.
Uninstall it from the strapi/ workspace:
cd strapi && yarn remove @strapi/plugin-users-permissionsThat drops the package from strapi/package.json, removes it from node_modules/, and updates the lockfile in one step.
This means you also lose the Public and Authenticated roles that users-permissions used to provide. Don't worry — we'll restore them via plugin-api-permissions in a moment, and your code won't notice because LaunchPad's existing code already routes auth through Better Auth (the previous branch state). If you're starting from a project that uses U&P for actual login or roles, plan a migration story for your existing users.
From strapi/:
yarn add \
better-auth \
@strapi-community/plugin-better-auth \
@strapi-community/plugin-api-permissions \
@strapi-community/plugin-better-auth-dashboard \
@better-auth/infra \
zod@^4.1.12If you're on npm, the equivalent is npm install --legacy-peer-deps better-auth @strapi-community/plugin-better-auth ... — same packages, same result. pnpm works identically.
| Package | Role | Links |
|---|---|---|
better-auth | The core auth library | docs · npm · github |
@strapi-community/plugin-better-auth | Strapi database adapter and route mounter | docs · readme · npm |
@strapi-community/plugin-api-permissions | Public + Authenticated roles, Content API RBAC | readme · npm |
@strapi-community/plugin-better-auth-dashboard | Admin panel UI for users / sessions | readme · npm |
@better-auth/infra | Peer dep of the dashboard's dash() plugin | npm |
zod@^4.1.12 | Workaround: see callout below. | docs · npm · github |
Why pin
zod@^4.1.12? The dashboard plugin callsz.email(), which only exists in zod 4. Strapi pulls in zod 3 transitively, so without an explicit top-level pin the dashboard crashes. You can skip the pin only if you also skip@better-auth/infra/dash()insrc/lib/auth.ts.
config/plugins.tsReplace the existing better-auth block with the three-plugin config:
1// strapi/config/plugins.ts
2export default () => ({
3 'better-auth': {
4 enabled: true,
5 },
6 'better-auth-dashboard': {
7 enabled: true,
8 },
9 'api-permissions': {
10 enabled: true,
11 },
12});src/lib/auth.tsThis step comes from the plugin's Installation guide, which tells you to put your betterAuth() call in src/lib/auth.ts. We add it because every Better Auth integration needs a single config file that does three things:
auth instance the Strapi runtime imports at boot to mount the /api/auth/* routes--config target so it can generate the matching content types in Step 9Create a new file at strapi/src/lib/auth.ts (make the lib/ folder if it doesn't exist) and paste in:
1// strapi/src/lib/auth.ts
2import { betterAuth } from 'better-auth';
3import { jwt } from 'better-auth/plugins';
4import { strapiAdapter } from '@strapi-community/plugin-better-auth';
5import { dash } from '@better-auth/infra';
6
7export const auth = betterAuth({
8 database: strapiAdapter(),
9 secret: process.env.BETTER_AUTH_SECRET,
10 baseURL: process.env.STRAPI_URL ?? 'http://localhost:1337',
11 trustedOrigins: [process.env.CLIENT_URL ?? 'http://localhost:3000'],
12 emailAndPassword: {
13 enabled: true,
14 requireEmailVerification: false,
15 },
16 session: {
17 expiresIn: 60 * 60 * 24 * 7, // 7 days
18 },
19 advanced: {
20 database: {
21 generateId: 'serial',
22 },
23 },
24 plugins: [
25 jwt(),
26 dash({
27 apiUrl: process.env.STRAPI_URL ?? 'http://localhost:1337',
28 apiKey:
29 process.env.BETTER_AUTH_DASHBOARD_SECRET ??
30 'strapi-internal-dashboard-key',
31 }),
32 ],
33});The plugin docs' Installation example is intentionally minimal — just database, trustedOrigins, and generateId. We add more on top because the tutorial uses email/password sign-up and the dashboard plugin. Line by line:
secret — signs sessions/JWTs. Auto-generated in dev with a warning; set it in .env (Step 7) for anything you ship.baseURL — Better Auth's own public URL, used to build callback URLs.trustedOrigins — origins allowed to call the auth endpoints (your Next.js app).emailAndPassword: { enabled: true } — required to opt into email/password sign-up; without it signUp.email() returns method not allowed. requireEmailVerification: false is already the default.session.expiresIn — 7-day sessions. Optional.advanced.database.generateId: 'serial' — required. Integer IDs that line up with Strapi's primary keys; omit and foreign keys blow up on first sign-up.jwt() — adds the JWKS table the dashboard signs its internal requests with.dash({...}) — wires the dashboard in. apiKey is the shared secret between dashboard and Strapi (real value in .env, Step 7).yarn setup already copied strapi/.env.example to strapi/.env. Open that file and append the two Better Auth secrets:
BETTER_AUTH_SECRET=replace-with-a-long-random-string
BETTER_AUTH_DASHBOARD_SECRET=replace-with-another-long-random-stringGenerate them with openssl rand -base64 32 (or anything similar). For local dev you can keep the placeholder strings, but rotate them before any non-local deployment.
plugin-api-permissions seeds the Public and Authenticated roles automatically on first boot — but with zero permissions attached. So even though your content API is now technically authorized, every anonymous GET /api/article returns 401.
You have two options: click each permission on in the admin UI under Settings → API Permissions → Roles → Public, or seed them in code. For a starter template with 12 content types, let's do it in code.
Open strapi/src/index.ts and replace the boilerplate with:
1// strapi/src/index.ts
2import type { Core } from '@strapi/strapi';
3
4const ROLE_UID = 'plugin::api-permissions.role';
5const PERMISSION_UID = 'plugin::api-permissions.permission';
6const PUBLIC_READ_ACTIONS = ['find', 'findOne'] as const;
7
8export default {
9 register() {},
10
11 async bootstrap({ strapi }: { strapi: Core.Strapi }) {
12 if (!strapi.plugin('api-permissions')) return;
13 // Defensive: skip if better-auth schema hasn't been generated yet.
14 // Without this guard, schema generation in Step 9 fails because
15 // api-permissions tries to count users on a content type that
16 // doesn't exist yet.
17 if (!strapi.contentTypes['plugin::better-auth.user' as never]) {
18 strapi.log.warn(
19 '[bootstrap] better-auth content types not found — run `npx -y @better-auth/cli generate --config src/lib/auth.ts --yes` first.',
20 );
21 return;
22 }
23
24 const documents = strapi.documents as any;
25
26 const publicRole = await documents(ROLE_UID).findFirst({
27 filters: { type: 'public' },
28 });
29
30 if (!publicRole) {
31 strapi.log.warn('[bootstrap] Public role not found — skipping permission seed.');
32 return;
33 }
34
35 const apiContentTypeUids = Object.keys(strapi.contentTypes).filter((uid) =>
36 uid.startsWith('api::'),
37 );
38
39 const existing: Array<{ action: string }> = await documents(PERMISSION_UID).findMany({
40 filters: { role: { documentId: publicRole.documentId } },
41 fields: ['action'],
42 });
43 const existingActions = new Set(existing.map((p) => p.action));
44
45 for (const uid of apiContentTypeUids) {
46 for (const action of PUBLIC_READ_ACTIONS) {
47 const actionKey = `${uid}.${action}`;
48 if (existingActions.has(actionKey)) continue;
49 await documents(PERMISSION_UID).create({
50 data: { action: actionKey, role: publicRole.id },
51 });
52 }
53 }
54 },
55};Two things worth calling out:
plugin-api-permissions crashes querying roles before the ba_* tables exist.strapi.documents as any cast is a workaround for missing TypeScript types on the api-permissions plugin's content types; without it, yarn seed fails at type-check.plugin-better-auth in beta ships with zero content types. You generate them from src/lib/auth.ts using the Better Auth CLI:
npx -y @better-auth/cli generate --config src/lib/auth.ts --yesYou should see output like:
1preparing schema...
2Your schema is now up to date.And new files in strapi/src/extensions/better-auth/content-types/:
1src/extensions/better-auth/content-types/
2├── account/schema.json
3├── jwks/schema.json ← added by jwt()
4├── session/schema.json
5├── user/schema.json
6└── verification/schema.jsonThe Better Auth tables are prefixed with ba_ by default (ba_user, ba_session, ba_account, ba_verification, ba_jwks). Re-run the generator every time you add or remove a Better Auth plugin in src/lib/auth.ts.
LaunchPad's main ships with a sign-up page but the form is a static UI mockup — there's no auth client, no submit handler, no sign-in page, and no user menu in the navbar. We need to add all of that. There are five edits here.
yarn setup already created next/.env from the example. The important key inside it is NEXT_PUBLIC_API_URL=http://localhost:1337. If it's missing, the Next.js Strapi client errors out before any page can render with Could not initialize the Strapi Client … Could not parse invalid URL: "/api".
Install the Better Auth client SDK:
cd next && yarn add better-auth1// next/lib/auth-client.ts
2import { createAuthClient } from 'better-auth/react';
3
4import { API_URL } from './utils';
5
6export const authClient = createAuthClient({
7 baseURL: `${API_URL}/api/auth`,
8});
9
10export const { signIn, signUp, signOut, useSession } = authClient;register.tsx with a submit handlerLaunchPad's existing register.tsx has a form but no handler. Replace it with a version that wires the form to signUp.email:
1// next/components/register.tsx
2'use client';
3
4import {
5 IconBrandGithubFilled,
6 IconBrandGoogleFilled,
7} from '@tabler/icons-react';
8import { Link } from 'next-view-transitions';
9import { useParams, useRouter } from 'next/navigation';
10import React, { useState } from 'react';
11
12import { Container } from './container';
13import { Button } from './elements/button';
14import { Logo } from './logo';
15import { signIn, signUp } from '@/lib/auth-client';
16
17export const Register = () => {
18 const router = useRouter();
19 const params = useParams<{ locale: string }>();
20 const locale = params?.locale ?? 'en';
21 const [name, setName] = useState('');
22 const [email, setEmail] = useState('');
23 const [password, setPassword] = useState('');
24 const [error, setError] = useState<string | null>(null);
25 const [isSubmitting, setIsSubmitting] = useState(false);
26
27 async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
28 e.preventDefault();
29 setError(null);
30 setIsSubmitting(true);
31
32 const { error: signUpError } = await signUp.email({
33 email,
34 password,
35 name: name || email,
36 });
37
38 setIsSubmitting(false);
39
40 if (signUpError) {
41 setError(signUpError.message ?? 'Sign up failed');
42 return;
43 }
44
45 router.push('/');
46 router.refresh();
47 }
48
49 async function handleSocial(provider: 'github' | 'google') {
50 setError(null);
51 const { error: socialError } = await signIn.social({
52 provider,
53 callbackURL: `/${locale}`,
54 });
55 if (socialError) {
56 setError(socialError.message ?? `${provider} sign-in failed`);
57 }
58 }
59
60 return (
61 <Container className="h-screen max-w-lg mx-auto flex flex-col items-center justify-center">
62 <Logo />
63 <h1 className="text-xl md:text-4xl font-bold my-4">
64 Sign up for LaunchPad
65 </h1>
66
67 <form className="w-full my-4" onSubmit={handleSubmit}>
68 <input
69 type="text"
70 placeholder="Name"
71 value={name}
72 onChange={(e) => setName(e.target.value)}
73 className="h-10 pl-4 w-full mb-4 rounded-md text-sm bg-charcoal border border-neutral-800 text-white placeholder-neutral-500 outline-none focus:outline-none active:outline-none focus:ring-2 focus:ring-neutral-800"
74 />
75 <input
76 type="email"
77 placeholder="Email Address"
78 value={email}
79 onChange={(e) => setEmail(e.target.value)}
80 required
81 className="h-10 pl-4 w-full mb-4 rounded-md text-sm bg-charcoal border border-neutral-800 text-white placeholder-neutral-500 outline-none focus:outline-none active:outline-none focus:ring-2 focus:ring-neutral-800"
82 />
83 <input
84 type="password"
85 placeholder="Password"
86 value={password}
87 onChange={(e) => setPassword(e.target.value)}
88 required
89 minLength={8}
90 className="h-10 pl-4 w-full mb-4 rounded-md text-sm bg-charcoal border border-neutral-800 text-white placeholder-neutral-500 outline-none focus:outline-none active:outline-none focus:ring-2 focus:ring-neutral-800"
91 />
92 {error && (
93 <p className="text-sm text-red-400 mb-4">{error}</p>
94 )}
95 <Button
96 variant="muted"
97 type="submit"
98 className="w-full py-3"
99 disabled={isSubmitting}
100 >
101 <span className="text-sm">{isSubmitting ? 'Signing up…' : 'Sign up'}</span>
102 </Button>
103 </form>
104
105 <p className="text-sm text-neutral-400">
106 Already have an account?{' '}
107 <Link
108 href={`/${locale}/sign-in`}
109 className="text-white underline underline-offset-2 hover:text-secondary"
110 >
111 Sign in
112 </Link>
113 </p>
114
115 <Divider />
116
117 <div className="flex flex-col sm:flex-row gap-4 w-full">
118 <button
119 type="button"
120 onClick={() => handleSocial('github')}
121 className="flex flex-1 justify-center space-x-2 items-center bg-white px-4 py-3 rounded-md text-black hover:bg-white/80 transition duration-200 shadow-[0px_1px_0px_0px_#00000040_inset]"
122 >
123 <IconBrandGithubFilled className="h-4 w-4 text-black" />
124 <span className="text-sm">Login with GitHub</span>
125 </button>
126 <button
127 type="button"
128 onClick={() => handleSocial('google')}
129 className="flex flex-1 justify-center space-x-2 items-center bg-white px-4 py-3 rounded-md text-black hover:bg-white/80 transition duration-200 shadow-[0px_1px_0px_0px_#00000040_inset]"
130 >
131 <IconBrandGoogleFilled className="h-4 w-4 text-black" />
132 <span className="text-sm">Login with Google</span>
133 </button>
134 </div>
135 </Container>
136 );
137};
138
139const Divider = () => {
140 return (
141 <div className="relative w-full py-8">
142 <div className="w-full h-px bg-neutral-700 rounded-tr-xl rounded-tl-xl" />
143 <div className="w-full h-px bg-neutral-800 rounded-br-xl rounded-bl-xl" />
144 <div className="absolute inset-0 h-5 w-5 m-auto rounded-md px-3 py-0.5 text-xs bg-neutral-800 shadow-[0px_-1px_0px_0px_var(--neutral-700)] flex items-center justify-center">
145 OR
146 </div>
147 </div>
148 );
149};LaunchPad doesn't have one. Create the route:
1// next/app/[locale]/sign-in/page.tsx
2import { AmbientColor } from '@/components/decorations/ambient-color';
3import { SignInForm } from '@/components/sign-in-form';
4
5export default function SignInPage() {
6 return (
7 <div className="relative overflow-hidden">
8 <AmbientColor />
9 <SignInForm />
10 </div>
11 );
12}And the form component — same shape as Register, but calling signIn.email({ email, password }) instead of signUp.email. Create next/components/sign-in-form.tsx:
1// next/components/sign-in-form.tsx
2'use client';
3
4import {
5 IconBrandGithubFilled,
6 IconBrandGoogleFilled,
7} from '@tabler/icons-react';
8import { Link } from 'next-view-transitions';
9import { useParams, useRouter } from 'next/navigation';
10import React, { useState } from 'react';
11
12import { Container } from './container';
13import { Button } from './elements/button';
14import { Logo } from './logo';
15import { signIn } from '@/lib/auth-client';
16
17export const SignInForm = () => {
18 const router = useRouter();
19 const params = useParams<{ locale: string }>();
20 const locale = params?.locale ?? 'en';
21 const [email, setEmail] = useState('');
22 const [password, setPassword] = useState('');
23 const [error, setError] = useState<string | null>(null);
24 const [isSubmitting, setIsSubmitting] = useState(false);
25
26 async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
27 e.preventDefault();
28 setError(null);
29 setIsSubmitting(true);
30
31 const { error: signInError } = await signIn.email({ email, password });
32
33 setIsSubmitting(false);
34
35 if (signInError) {
36 setError(signInError.message ?? 'Sign in failed');
37 return;
38 }
39
40 router.push('/');
41 router.refresh();
42 }
43
44 async function handleSocial(provider: 'github' | 'google') {
45 setError(null);
46 const { error: socialError } = await signIn.social({
47 provider,
48 callbackURL: `/${locale}`,
49 });
50 if (socialError) {
51 setError(socialError.message ?? `${provider} sign-in failed`);
52 }
53 }
54
55 return (
56 <Container className="h-screen max-w-lg mx-auto flex flex-col items-center justify-center">
57 <Logo />
58 <h1 className="text-xl md:text-4xl font-bold my-4">
59 Sign in to LaunchPad
60 </h1>
61
62 <form className="w-full my-4" onSubmit={handleSubmit}>
63 <input
64 type="email"
65 placeholder="Email Address"
66 value={email}
67 onChange={(e) => setEmail(e.target.value)}
68 required
69 className="h-10 pl-4 w-full mb-4 rounded-md text-sm bg-charcoal border border-neutral-800 text-white placeholder-neutral-500 outline-none focus:outline-none active:outline-none focus:ring-2 focus:ring-neutral-800"
70 />
71 <input
72 type="password"
73 placeholder="Password"
74 value={password}
75 onChange={(e) => setPassword(e.target.value)}
76 required
77 className="h-10 pl-4 w-full mb-4 rounded-md text-sm bg-charcoal border border-neutral-800 text-white placeholder-neutral-500 outline-none focus:outline-none active:outline-none focus:ring-2 focus:ring-neutral-800"
78 />
79 {error && <p className="text-sm text-red-400 mb-4">{error}</p>}
80 <Button
81 variant="muted"
82 type="submit"
83 className="w-full py-3"
84 disabled={isSubmitting}
85 >
86 <span className="text-sm">{isSubmitting ? 'Signing in…' : 'Sign in'}</span>
87 </Button>
88 </form>
89
90 <p className="text-sm text-neutral-400">
91 Don't have an account?{' '}
92 <Link
93 href={`/${locale}/sign-up`}
94 className="text-white underline underline-offset-2 hover:text-secondary"
95 >
96 Sign up
97 </Link>
98 </p>
99
100 <Divider />
101
102 <div className="flex flex-col sm:flex-row gap-4 w-full">
103 <button
104 type="button"
105 onClick={() => handleSocial('github')}
106 className="flex flex-1 justify-center space-x-2 items-center bg-white px-4 py-3 rounded-md text-black hover:bg-white/80 transition duration-200 shadow-[0px_1px_0px_0px_#00000040_inset]"
107 >
108 <IconBrandGithubFilled className="h-4 w-4 text-black" />
109 <span className="text-sm">Login with GitHub</span>
110 </button>
111 <button
112 type="button"
113 onClick={() => handleSocial('google')}
114 className="flex flex-1 justify-center space-x-2 items-center bg-white px-4 py-3 rounded-md text-black hover:bg-white/80 transition duration-200 shadow-[0px_1px_0px_0px_#00000040_inset]"
115 >
116 <IconBrandGoogleFilled className="h-4 w-4 text-black" />
117 <span className="text-sm">Login with Google</span>
118 </button>
119 </div>
120 </Container>
121 );
122};
123
124const Divider = () => {
125 return (
126 <div className="relative w-full py-8">
127 <div className="w-full h-px bg-neutral-700 rounded-tr-xl rounded-tl-xl" />
128 <div className="w-full h-px bg-neutral-800 rounded-br-xl rounded-bl-xl" />
129 <div className="absolute inset-0 h-5 w-5 m-auto rounded-md px-3 py-0.5 text-xs bg-neutral-800 shadow-[0px_-1px_0px_0px_var(--neutral-700)] flex items-center justify-center">
130 OR
131 </div>
132 </div>
133 );
134};1// next/components/navbar/user-menu.tsx
2'use client';
3
4import { useRouter } from 'next/navigation';
5import { useState } from 'react';
6
7import { Button } from '@/components/elements/button';
8import { signOut, useSession } from '@/lib/auth-client';
9
10export const UserMenu = ({ locale }: { locale: string }) => {
11 const router = useRouter();
12 const { data: session, isPending } = useSession();
13 const [isSigningOut, setIsSigningOut] = useState(false);
14
15 if (isPending) return null;
16 if (!session?.user) return null;
17
18 const displayName = session.user.name || session.user.email;
19
20 async function handleSignOut() {
21 setIsSigningOut(true);
22 await signOut();
23 setIsSigningOut(false);
24 router.push(`/${locale}`);
25 router.refresh();
26 }
27
28 return (
29 <div className="flex items-center gap-2">
30 <span className="text-white text-sm whitespace-nowrap">Hi {displayName}</span>
31 <Button variant="simple" onClick={handleSignOut} disabled={isSigningOut}>
32 {isSigningOut ? 'Logging out…' : 'Logout'}
33 </Button>
34 </div>
35 );
36};UserMenu in the desktop and mobile navbarsReplace each navbar file with the version below — the only behavioral change is wrapping the rightNavbarItems map in session?.user ? <UserMenu /> : (...) so the menu shows when signed in and the original sign-up/sign-in buttons show otherwise.
next/components/navbar/desktop-navbar.tsx
1// next/components/navbar/desktop-navbar.tsx
2'use client';
3
4import {
5 AnimatePresence,
6 motion,
7 useMotionValueEvent,
8 useScroll,
9} from 'framer-motion';
10import { Link } from 'next-view-transitions';
11import { useState } from 'react';
12
13import { LocaleSwitcher } from '../locale-switcher';
14import { NavbarItem } from './navbar-item';
15import { UserMenu } from './user-menu';
16import { Button } from '@/components/elements/button';
17import { Logo } from '@/components/logo';
18import { useSession } from '@/lib/auth-client';
19import { cn } from '@/lib/utils';
20
21type Props = {
22 leftNavbarItems: {
23 URL: string;
24 text: string;
25 target?: string;
26 }[];
27 rightNavbarItems: {
28 URL: string;
29 text: string;
30 target?: string;
31 }[];
32 logo: any;
33 locale: string;
34};
35
36export const DesktopNavbar = ({
37 leftNavbarItems,
38 rightNavbarItems,
39 logo,
40 locale,
41}: Props) => {
42 const { scrollY } = useScroll();
43 const { data: session } = useSession();
44
45 const [showBackground, setShowBackground] = useState(false);
46
47 useMotionValueEvent(scrollY, 'change', (value) => {
48 if (value > 100) {
49 setShowBackground(true);
50 } else {
51 setShowBackground(false);
52 }
53 });
54 return (
55 <motion.div
56 className={cn(
57 'w-full flex relative justify-between px-4 py-3 rounded-md transition duration-200 bg-transparent mx-auto'
58 )}
59 animate={{
60 width: showBackground ? '80%' : '100%',
61 background: showBackground ? 'var(--neutral-900)' : 'transparent',
62 }}
63 transition={{
64 duration: 0.4,
65 }}
66 >
67 <AnimatePresence>
68 {showBackground && (
69 <motion.div
70 key={String(showBackground)}
71 initial={{ opacity: 0 }}
72 animate={{ opacity: 1 }}
73 transition={{
74 duration: 1,
75 }}
76 className="absolute inset-0 h-full w-full bg-neutral-900 pointer-events-none [mask-image:linear-gradient(to_bottom,white,transparent,white)] rounded-full"
77 />
78 )}
79 </AnimatePresence>
80 <div className="flex flex-row gap-2 items-center">
81 <Logo locale={locale} image={logo?.image} />
82 <div className="flex items-center gap-1.5">
83 {leftNavbarItems.map((item) => (
84 <NavbarItem
85 href={`/${locale}${item.URL}` as never}
86 key={item.text}
87 target={item.target}
88 >
89 {item.text}
90 </NavbarItem>
91 ))}
92 </div>
93 </div>
94 <div className="flex space-x-2 items-center">
95 <LocaleSwitcher currentLocale={locale} />
96
97 {session?.user ? (
98 <UserMenu locale={locale} />
99 ) : (
100 rightNavbarItems.map((item, index) => (
101 <Button
102 key={item.text}
103 variant={
104 index === rightNavbarItems.length - 1 ? 'primary' : 'simple'
105 }
106 as={Link}
107 href={`/${locale}${item.URL}`}
108 >
109 {item.text}
110 </Button>
111 ))
112 )}
113 </div>
114 </motion.div>
115 );
116};next/components/navbar/mobile-navbar.tsx
1// next/components/navbar/mobile-navbar.tsx
2'use client';
3
4import { useMotionValueEvent, useScroll } from 'framer-motion';
5import { Link } from 'next-view-transitions';
6import { useState } from 'react';
7import { IoIosMenu, IoIosClose } from 'react-icons/io';
8
9import { LocaleSwitcher } from '../locale-switcher';
10import { UserMenu } from './user-menu';
11import { Button } from '@/components/elements/button';
12import { Logo } from '@/components/logo';
13import { useSession } from '@/lib/auth-client';
14import { cn } from '@/lib/utils';
15
16type Props = {
17 leftNavbarItems: {
18 URL: string;
19 text: string;
20 target?: string;
21 }[];
22 rightNavbarItems: {
23 URL: string;
24 text: string;
25 target?: string;
26 }[];
27 logo: any;
28 locale: string;
29};
30
31export const MobileNavbar = ({
32 leftNavbarItems,
33 rightNavbarItems,
34 logo,
35 locale,
36}: Props) => {
37 const [open, setOpen] = useState(false);
38 const { data: session } = useSession();
39
40 const { scrollY } = useScroll();
41
42 const [showBackground, setShowBackground] = useState(false);
43
44 useMotionValueEvent(scrollY, 'change', (value) => {
45 if (value > 100) {
46 setShowBackground(true);
47 } else {
48 setShowBackground(false);
49 }
50 });
51
52 return (
53 <div
54 className={cn(
55 'flex justify-between bg-transparent items-center w-full rounded-md px-2.5 py-1.5 transition duration-200',
56 showBackground &&
57 ' bg-neutral-900 shadow-[0px_-2px_0px_0px_var(--neutral-800),0px_2px_0px_0px_var(--neutral-800)]'
58 )}
59 >
60 <Logo image={logo?.image} />
61
62 <IoIosMenu
63 className="text-white h-6 w-6"
64 onClick={() => setOpen(!open)}
65 />
66
67 {open && (
68 <div className="fixed inset-0 bg-black z-50 flex flex-col items-start justify-start space-y-10 pt-5 text-xl text-zinc-600 transition duration-200 hover:text-zinc-800">
69 <div className="flex items-center justify-between w-full px-5">
70 <Logo locale={locale} image={logo?.image} />
71 <div className="flex items-center space-x-2">
72 <LocaleSwitcher currentLocale={locale} />
73 <IoIosClose
74 className="h-8 w-8 text-white"
75 onClick={() => setOpen(!open)}
76 />
77 </div>
78 </div>
79 <div className="flex flex-col items-start justify-start gap-[14px] px-8">
80 {leftNavbarItems.map((navItem: any, idx: number) => (
81 <>
82 {navItem.children && navItem.children.length > 0 ? (
83 <>
84 {navItem.children.map((childNavItem: any, idx: number) => (
85 <Link
86 key={`link=${idx}`}
87 href={`/${locale}${childNavItem.URL}`}
88 onClick={() => setOpen(false)}
89 className="relative max-w-[15rem] text-left text-2xl"
90 suppressHydrationWarning
91 >
92 <span className="block text-white">
93 {childNavItem.text}
94 </span>
95 </Link>
96 ))}
97 </>
98 ) : (
99 <Link
100 key={`link=${idx}`}
101 href={`/${locale}${navItem.URL}`}
102 onClick={() => setOpen(false)}
103 className="relative"
104 suppressHydrationWarning
105 >
106 <span className="block text-[26px] text-white">
107 {navItem.text}
108 </span>
109 </Link>
110 )}
111 </>
112 ))}
113 </div>
114 <div className="flex flex-row w-full items-start gap-2.5 px-8 py-4 ">
115 {session?.user ? (
116 <UserMenu locale={locale} />
117 ) : (
118 rightNavbarItems.map((item, index) => (
119 <Button
120 key={item.text}
121 variant={
122 index === rightNavbarItems.length - 1 ? 'primary' : 'simple'
123 }
124 as={Link}
125 href={`/${locale}${item.URL}`}
126 >
127 {item.text}
128 </Button>
129 ))
130 )}
131 </div>
132 </div>
133 )}
134 </div>
135 );
136};Shortcut: since the frontend changes are scoped to those five files, the fastest way to apply this step is to copy them from the
launchpad-better-auth-examplerepo into yournext/folder. The Strapi-side configuration is what makes this tutorial different from a generic Better Auth setup — the frontend is just a stock Better Auth React client wiring.
Because we changed the schema (new ba_* tables, new api_permissions_* tables), wipe the local dev DB and reimport the seed data:
rm -f strapi/.tmp/data.db
cd strapi && yes | yarn seed(If you're using PostgreSQL or another database, drop and recreate the database instead of deleting .tmp/data.db.)
From the repo root:
yarn devYou should see Strapi start cleanly with no plugin errors. Open:
You will have to create a new Strapi Admin user.
Once created, you should be able to login and got to the Better Auth dashboard.
Navigate to http://localhost:3000/en/sign-up, fill in name / email / password, and submit. You should see:
POST to http://localhost:1337/api/auth/sign-up/email returning 200 with {token, user: {id, name, email, ...}}localhost:1337/curl -X POST http://localhost:1337/api/auth/sign-up/email \
-H 'content-type: application/json' \
-d '{"email":"test@example.com","password":"testpass1234","name":"Test"}'You should get a 200 with a token and user object.
Then jump to the Strapi admin's Better Auth tab — your new user appears in the user list, with metrics, growth chart, and the ability to revoke sessions or ban accounts.
After all that, here's what's different from users-permissions:
| Concern | users-permissions | Better Auth stack |
|---|---|---|
| Sign-up endpoint | /api/auth/local/register | /api/auth/sign-up/email |
| Sign-in endpoint | /api/auth/local | /api/auth/sign-in/email |
| User content type | plugin::users-permissions.user | plugin::better-auth.user (table ba_user) |
| Role/permission UI | Settings → Users & Permissions → Roles | Settings → API Permissions → Roles |
| Admin user management | Content Manager → User | Better Auth dashboard tab (search, metrics, sessions, ban) |
| Social providers | requires custom controller code | one line per provider in auth.ts |
| Two-factor auth | not supported out of the box | one line: add twoFactor() plugin |
| Magic links | requires custom code | one line: add magicLink() plugin |
| Frontend SDK | none — fetch by hand | better-auth/react: useSession, signIn, signUp, signOut |
The dashboard alone is a big quality-of-life upgrade — DAU / WAU / MAU, growth chart, cohort retention, per-user session list with revoke buttons.
A few sharp edges worth being aware of:
Error: The 'users-permissions' plugin is installed. at Strapi boot — you didn't fully remove @strapi/plugin-users-permissions in Step 3. The plugin checks for the package, not the enabled state, so disabling in config/plugins.ts isn't enough. Remove it from package.json and reinstall.TypeError: z.email is not a function during schema generation — you skipped the zod@^4.1.12 install. Add it and reinstall.401 Unauthorized on every /api/* content endpoint — boot Strapi once after schema generation so the bootstrap in src/index.ts can seed the Public role's permissions. If you're still seeing 401s, check Settings → API Permissions → Roles → Public in the admin and toggle find / findOne on each content type manually.PaulBratslavsky/launchpad-better-auth-example repo. Treat it as the canonical reference implementation. Use git diff against strapi/LaunchPad@main to see the exact set of edits.BETTER-AUTH-SETUP.md in that repo is a concise reference if you don't want the narrative version.better-auth-setup Claude Code skill automates these steps against any Strapi + Next.js project.src/lib/auth.ts — see the Better Auth providers docs.src/lib/auth.ts and re-run npx -y @better-auth/cli generate --config src/lib/auth.ts --yes.This is the direction the Strapi team is moving — giving you the option to use Better Auth in your projects instead of users-permissions. Thank you for trying out the feature. Please share feedback, ideas, or bug reports in the strapi-community/plugin-better-auth repo — that's where the maintainers triage issues for all three plugins.
Citations