Better Auth + Prisma: Secure Auth in Next.js App Router

npmixnpmix
22
Developer wiring Better Auth with Prisma and Next.js

I’ve tried a dozen authentication setups in modern React apps.
Some felt like magic. Others… like pain.
Here’s how I finally set up auth right — using Better Auth, Prisma, and the App Router in Next.js, with clean form UIs via shadcn/ui.


🧱 Why Better Auth?

I was tired of overcomplicated adapters, magic cookies, and digging through outdated docs.

Better Auth felt refreshingly focused:

  • Email + password out of the box
  • Prisma adapter included
  • Built for App Router, not shoehorned in

So I spun up a clean Next.js project and got to work.


⚙️ The Stack

  • Next.js 14+ with App Router
  • Prisma ORM with PostgreSQL
  • Better Auth
  • shadcn/ui for forms
  • Tailwind CSS

🛠️ Project Setup

bash
npx create-next-app@latest betterauth-prisma-starter

Choose:

  • TypeScript: Yes
  • Tailwind CSS: Yes
  • App Router: Yes
  • Inside /src: Yes
  • Eslint: Yes
  • Turbopack: Yes
  • Customize imports: No

For installation, you can follow the steps on Next.js or on Shadcn

Now navigate into the project :

bash
cd betterauth-prisma-starter

Then install your auth stack:

bash
npm install prisma --save-dev
npm install @prisma/client @prisma/extension-accelerate
npx prisma init

To generate the prisma client, run this command:

bash
npx prisma generate

Now, install better-auth,

bash
npm install better-auth

Generate a secure secret for your authentification

bash
npx @better-auth/cli@latest secret

🧪 Configure Prisma + Better Auth

Edit your .env:

env
BETTER_AUTH_SECRET=generated-secret
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=your-prisma-db-url

🔧 Create a prisma.ts file

ts
import { PrismaClient } from "@/generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

const prisma =
  globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate());

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export default prisma;

🔐 Auth config

Create a auth.ts file in the lib directory and add this code

ts
import { prisma } from "@/db/prisma";

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";

export const auth = betterAuth({
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    },
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    },
  },
  emailAndPassword: {
    enabled: true,
  },
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  plugins: [nextCookies()],
});

In order to use and modify cookies while using server actions, you must add nextCookies() to the configuration of the auth.js file.

This is a very useful plugin when using the signInEmail or signUpEmail functions during authentication. A set Cookies call will be launched automatically in your code.

ts
"use server";

import { auth } from "@/lib/auth";

export const signIn = async (email: string, password: string) => {
  try {
    await auth.api.signInEmail({
      body: {
        email,
        password,
      },
    });

    return {
      success: true,
      message: "Signed in successfully.",
    };
  } catch (error) {
    const e = error as Error;

    return {
      success: false,
      message: e.message || "An unknown error occurred.",
    };
  }
};
ts
import { signIn } from "@/server/users";
import { toast } from "sonner";

...
async function onSubmit(values: z.infer<typeof formSchema>) {
  const { success, message } = await signIn(values.email, values.password);

  if (success) {
    toast.success(message as string);
    router.push("/dashboard");
  } else {
    toast.error(message as string);
  }
}
...

After that, create the schema.prisma file, which is the database model management file.

ℹ️ For better-auth to work and interact with Prisma, the User, Session, Account, and Verification models must exist and be properly configured in the schema.prisma file. You can use this code for basic use.

ts
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id            String    @id
  name          String
  email         String
  emailVerified Boolean
  image         String?
  createdAt     DateTime
  updatedAt     DateTime
  sessions      Session[]
  accounts      Account[]

  @@unique([email])
  @@map("user")
}

model Session {
  id        String   @id
  expiresAt DateTime
  token     String
  createdAt DateTime
  updatedAt DateTime
  ipAddress String?
  userAgent String?
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([token])
  @@map("session")
}

model Account {
  id                    String    @id
  accountId             String
  providerId            String
  userId                String
  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  accessToken           String?
  refreshToken          String?
  idToken               String?
  accessTokenExpiresAt  DateTime?
  refreshTokenExpiresAt DateTime?
  scope                 String?
  password              String?
  createdAt             DateTime
  updatedAt             DateTime

  @@map("account")
}

model Verification {
  id         String    @id
  identifier String
  value      String
  expiresAt  DateTime
  createdAt  DateTime?
  updatedAt  DateTime?

  @@map("verification")
}

The file configuration is ready. Now run the following commands to migrate the prisma schema with the Better-auth models

Run:

ts
npx @better-auth/cli generate
npx prisma migrate dev --name init-migration

This code manages password authentication with the option emailAndPassword: true. Here, we have also integrated social providers to connect with Google and Github.

To generate access to GitHub authentication, follow these steps: -Go to your profile, then at the bottom of the sidebar, -There is the Developer settings option. -Enter and select OAuth Apps, -Fill out the form


🧩 Add Catch-All Route

Create a route file in your application and place it at src/app/api/auth/[...all]/route.ts. It's pretty much the same as the Auth.js configuration.

If you want to know how to switch from Authjs to Better auth, you can see it clearly here.

Integration is easy, but I'll show you how to do it more quickly so you don't get lost. Here, we are using the Next.js App Router. You can find more information about integrating Better-auth and next.js.

ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { POST, GET } = toNextJsHandler(auth);

Client auth hooks:

For the client interface, create the src/lib/auth-client.ts file according to your project structure.

ts
import { createAuthClient } from "better-auth/react";

export const { signIn, signUp, signOut, useSession } = createAuthClient();

To do this, you can use client-mode authentication. Network authentication management will be simpler. Logging out will also be simplified.

ts
const signInWithGoogle = async () => {
  await authClient.signIn.social({
    provider: "google",
    callbackURL: "/dashboard",
  });
};

🧾 Sign-Up Form (with Shadcn UI)

tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";

import { signIn } from "@/server/users";

import { z } from "zod";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";

const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export function LoginForm({className, ...props}: React.ComponentProps<"div">) {
  const [isLoading, setIsLoading] = useState(false);

  const router = useRouter();
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const signInWithGoogle = async () => {
    await authClient.signIn.social({
      provider: "google",
      callbackURL: "/dashboard",
    });
  };

  async function onSubmit(values: z.infer<typeof formSchema>) {
    setIsLoading(true);

    const { success, message } = await signIn(values.email, values.password);

    if (success) {
      toast.success(message as string);
      router.push("/dashboard");
    } else {
      toast.error(message as string);
    }

    setIsLoading(false);
  }

  return (
    <div className={cn("flex flex-col gap-6", className)} {...props}>
      <Card>
        <CardHeader className="text-center">
          <CardTitle className="text-xl">Welcome back</CardTitle>
          <CardDescription>Login with your Google account</CardDescription>
        </CardHeader>
        <CardContent>
          <Form {...form}>
            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
              <div className="grid gap-6">
                <div className="flex flex-col gap-4">
                  <Button
                    variant="outline"
                    className="w-full"
                    type="button"
                    onClick={signInWithGoogle}
                  >
                    <GoogleIcon />
                    Login with Google
                  </Button>
                </div>
                <div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
                  <span className="bg-card text-muted-foreground relative z-10 px-2">
                    Or continue with
                  </span>
                </div>
                <div className="grid gap-6">
                  <div className="grid gap-3">
                    <FormField
                      control={form.control}
                      name="email"
                      render={({ field }) => (
                        <FormItem>
                          <FormLabel>Email</FormLabel>
                          <FormControl>
                            <Input placeholder="m@example.com" {...field} />
                          </FormControl>
                          <FormMessage />
                        </FormItem>
                      )}
                    />
                  </div>
                  <div className="grid gap-3">
                    <div className="flex flex-col gap-2">
                      <FormField
                        control={form.control}
                        name="password"
                        render={({ field }) => (
                          <FormItem>
                            <FormLabel>Password</FormLabel>
                            <FormControl>
                              <Input
                                placeholder="********"
                                {...field}
                                type="password"
                              />
                            </FormControl>
                            <FormMessage />
                          </FormItem>
                        )}
                      />
                    </div>
                  </div>
                  <Button type="submit" className="w-full" disabled={isLoading}>
                    {isLoading ? (
                      <Loader2 className="size-4 animate-spin" />
                    ) : (
                      "Login"
                    )}
                  </Button>
                </div>
                <div className="text-center text-sm">
                  Don&apos;t have an account?{" "}
                  <Link href="/signup" className="underline underline-offset-4">
                    Sign up
                  </Link>
                </div>
              </div>
            </form>
          </Form>
        </CardContent>
      </Card>
    </div>
  );
}

👋 If this helped…

👉 Subscribe for updates on auth, App Router, and production-ready stacks.
💬 Share this if your team is migrating from Auth.js or building a new stack.

⚡Follow me on X and Bsky


🧠 Source

Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.