Next.js, Auth.js, Drizzle ORM, SQLite, Credentials Provider Tutorial

Learn how to set up Authentication with Next.js, Auth.js, Drizzle ORM, SQLite, and Credentials Provider.

This setup with Credentials Provider is intended for rapid prototyping and proof of concepts. For production apps, it is recommended to use OAuth or Email authentication.

Step 1: Initialize Next.js project

npx create-next-app@latest

Step 2: Install dependencies

npm install next-auth@beta drizzle-orm @auth/drizzle-adapter better-sqlite3 bcrypt
npm install drizzle-kit @types/better-sqlite3 @types/bcrypt --save-dev

Step 3: Load environment variables

/lib/config.ts

import { loadEnvConfig } from "@next/env";

const projectDir = process.cwd();
loadEnvConfig(projectDir);

const config = {
  DATABASE_URL: process.env.DATABASE_URL!,
};

export default config;

Next.js automatically loads environment variables from .env.local, however, Drizzle Kit does not. So, we'll need to call loadEnvConfig manually.

Step 4: Define schema

/lib/schema.ts

import {
  integer,
  sqliteTable,
  text,
  primaryKey,
} from "drizzle-orm/sqlite-core";
import type { AdapterAccount } from "@auth/core/adapters";

export const users = sqliteTable("user", {
  id: text("id").notNull().primaryKey(),
  name: text("name"),
  email: text("email"),
  emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
  image: text("image"),
  password: text("password"),
});

export const accounts = sqliteTable(
  "account",
  {
    userId: text("userId")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    type: text("type").$type<AdapterAccount["type"]>().notNull(),
    provider: text("provider").notNull(),
    providerAccountId: text("providerAccountId").notNull(),
    refresh_token: text("refresh_token"),
    access_token: text("access_token"),
    expires_at: integer("expires_at"),
    token_type: text("token_type"),
    scope: text("scope"),
    id_token: text("id_token"),
    session_state: text("session_state"),
  },
  (account) => ({
    compoundKey: primaryKey({
      columns: [account.provider, account.providerAccountId],
    }),
  })
);

export const sessions = sqliteTable("session", {
  sessionToken: text("sessionToken").notNull().primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
});

export const verificationTokens = sqliteTable(
  "verificationToken",
  {
    identifier: text("identifier").notNull(),
    token: text("token").notNull(),
    expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
  },
  (vt) => ({
    compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
  })
);

The schema was copied and pasted from the Auth.js docs. We remove the notNull constraint from email, as we will only use the username for prototyping.

Step 5: Create the Drizzle DB instance

/lib/db.ts

import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import * as schema from "@/lib/schema";
import config from "./config";

const sqlite = new Database(config.DATABASE_URL);
export const db = drizzle(sqlite, { schema, logger: true });

Step 6: Configure Auth.js

/lib/auth.ts

import { DrizzleAdapter } from "@auth/drizzle-adapter";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { db } from "./db";
import bcrypt from "bcrypt";
import { eq } from "drizzle-orm";
import { users } from "./schema";

function passwordToSalt(password: string) {
  const saltRounds = 10;
  const hash = bcrypt.hashSync(password, saltRounds);
  return hash;
}

async function getUserFromDb(username: string) {
  const user = await db.query.users.findFirst({
    where: eq(users.name, username),
  });
  return user;
}

async function addUserToDb(username: string, saltedPassword: string) {
  const user = await db
    .insert(users)
    .values({
      id: crypto.randomUUID(),
      name: username,
      password: saltedPassword,
    })
    .returning();
  return user.pop();
}

export const {
  handlers: { GET, POST },
  auth,
} = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        username: { label: "Username", type: "text", placeholder: "jsmith" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials, req) {
        let user = null;
        const username = credentials.username as string;
        const password = credentials.password as string;

        if (!username || !password) {
          return null;
        }

        user = await getUserFromDb(username);

        if (user) {
          if (!user.password) {
            return null;
          }

          const isAuthenciated = await bcrypt.compare(password, user.password);
          if (isAuthenciated) {
            return user;
          } else {
            return null;
          }
        }

        if (!user) {
          const saltedPassword = passwordToSalt(password);
          user = await addUserToDb(username, saltedPassword);
        }

        if (!user) {
          throw new Error("User was not found and could not be created.");
        }

        return user;
      },
    }),
  ],
  callbacks: {
    async session({ session, user, token }: any) {
      return session;
    },
  },
  session: {
    strategy: "jwt",
  },
});

The session callback is required to add the user id to the user object.

Setting the session strategy to jwt is important for credentials provider, otherwise the user object will not be included in the session object.

Step 7: Create Auth API route

/app/api/auth/[...nextauth]/route.ts

export { GET, POST } from "@/lib/auth";

Step 8: Extend Next Auth Type With Module Augmentation

/types/next-auth.d.ts

import NextAuth from "next-auth";

declare module "next-auth" {
  /**
   * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
   */
  interface Session {
    user: {
      id: string;
      name: string;
    };
  }
}

It is important to import NextAuth here to fix a TypeScript error in auth.ts.

Step 9: Sign In Button

/components/signin-btn-auto.tsx

"use client";
import { useSession, signIn, signOut } from "next-auth/react";

export default function SignInBtnAuto() {
  const { data: session } = useSession();
  if (session) {
    return (
      <>
        Signed in as {session.user?.name} <br />
        <button onClick={() => signOut()}>Sign out</button>
      </>
    );
  }
  return (
    <>
      Not signed in <br />
      <button onClick={() => signIn()}>Sign in</button>
    </>
  );
}

This button uses the prebuilt sign in page from Auth.js.

Step 10: Session Provider

/components/session-provider.tsx

"use client";
import { SessionProvider } from "next-auth/react";
export default SessionProvider;

Use this session provider on any layout or component that needs to check user session.

Step 11: Use the Session Provider

/app/(dynamic)/layout.tsx

import { ReactNode } from "react";
import SessionProvider from "@/components/session-provider";
import { auth } from "@/lib/auth";

export default async function Layout({ children }: { children: ReactNode }) {
  const session = await auth();
  return (
    <SessionProvider session={session}>
      <div>{children}</div>
    </SessionProvider>
  );
}

We are using a route group to avoid putting the session provider on the root layout so that we don't force static pages to become dynamic. For example, static marketing pages. It's good to keep the option to create static pages in the future.

Step 12: Add the sign in button to the sign in page

/app/(dynamic)/signin/page.tsx

import SignInBtn from "@/components/signin-btn";

export default function Page() {
  return (
    <div>
      <SignInBtn />
    </div>
  );
}

Step 13: Create env local file

The Auth Secret can be any random string. Auth.js has a tool to generate a secret.

npx auth secret

Copy and paste the secret into .env.local.

DATABASE_URL="db.sqlite"
AUTH_SECRET="exOpw3cJVq70C8bnoJjSmW76wFRJU5Ou/pmLmN3ypxc="

Create a .env.example file for future reference.

DATABASE_URL=
AUTH_SECRET=

Step 14: Configure Drizzle

/drizzle.config.ts

import config from "@/lib/config";
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./lib/schema.ts",
  out: "./drizzle",
  driver: "better-sqlite",
  dbCredentials: {
    url: config.DATABASE_URL,
  },
  verbose: true,
  strict: true,
});

Step 15: Run the push command

npx drizzle-kit push:sqlite

This command will sync the schema to the database.

Step 16: Test the sign in page

Test the login by going to the sign in page and signing in. http://localhost:3000/signin

Step 17: Create a protected page

/app/(dynamic)/protected/page.tsx

import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function Page() {
  const session = await auth();
  if (!session) {
    redirect("/signin");
  }
  return <div>{session.user.name}</div>;
}

Test by visiting http://localhost:3000/protected.

Step 18: Create a protected API Route

/app/api/protected/route.ts

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

export async function GET() {
  const session = await auth();
  if (!session) {
    return Response.json({ message: "unauthenticated" }, { status: 401 });
  }
  return Response.json({ name: session?.user.name });
}

Test by sending a GET request to http://localhost:3000/api/protected.

Step 19: Add sqlite files to the gitignore

We don't want to check sqlite databases into the Git repo.

.gitignore

*.sqlite

Step 20: Ready for production?

This SQLite boilerplate is designed for rapid prototyping. Here are a few recommendations if launching to production.

  • Switch to using a production grade database like PostgreSQL.
  • Switch to using migrations instead of the push command.
  • Switch to using OAuth or Email Provider instead of Credentials Provider.
  • If using Credentials Provider, implement an email verification and password reset process.
  • Add notNull constraints to email and username.
  • Create a custom sign in page instead of using the prebuilt one by Auth.js.

References