Skip to main content

Overview

GTM Feedback uses NextAuth v5 (Auth.js) for authentication with Google OAuth provider. User data is stored in PostgreSQL using Drizzle ORM with built-in support for sessions and role-based access control.

NextAuth v5 Setup

Authentication is configured in apps/www/src/lib/auth.ts:
apps/www/src/lib/auth.ts
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@feedback/db";
import { accounts, sessions, users } from "@feedback/db/schema";
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";

export const { auth, handlers, signIn, signOut } = NextAuth({
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_CLIENT_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
    }),
  ],
  pages: {
    signIn: "/login",
    newUser: "/",
  },
  adapter: DrizzleAdapter(db, {
    usersTable: users,
    accountsTable: accounts,
    sessionsTable: sessions,
  }),
  callbacks: {
    authorized: async () => {
      // Allow public access - anyone can view content
      return true;
    },
    async session({ session, user }) {
      // Attach user id/avatar/isAdmin to session.user
      if (session.user) {
        session.user.id = user.id;
        session.user.avatar = user.avatar;
        session.user.isAdmin = true;
      }
      return session;
    },
  },
});

Environment Variables

Add these to your .env file:
# NextAuth
AUTH_SECRET=your_nextauth_secret_key
AUTH_GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com
AUTH_GOOGLE_SECRET=your_google_client_secret

# Database
DATABASE_URL=postgresql://user:pass@host/database
Generate a secure AUTH_SECRET with: openssl rand -base64 32

Google OAuth Configuration

Create OAuth Credentials

1

Open Google Cloud Console

Navigate to Google Cloud Console and create or select a project.
2

Enable Google+ API

Go to APIs & Services > Library and enable the “Google+ API”.
3

Create OAuth Client ID

Go to APIs & Services > Credentials and click Create Credentials > OAuth Client ID.Select Web application and configure:
  • Authorized JavaScript origins: http://localhost:3000 (development)
  • Authorized redirect URIs:
    • http://localhost:3000/api/auth/callback/google (development)
    • https://yourdomain.com/api/auth/callback/google (production)
4

Copy credentials

Copy the Client ID and Client Secret to your .env file.
Configure the OAuth consent screen:
  • User Type: Internal (for Google Workspace) or External
  • Scopes: email, profile, openid (automatically requested)
  • Test Users: Add emails for testing if using External type

Database Schema

User authentication data is stored in PostgreSQL:
packages/database/src/schema.ts
export const users = pgTable("users", {
  id: uuid().defaultRandom().primaryKey().notNull(),
  name: text("name").notNull(),
  email: text("email").unique().notNull(),
  image: text("image"),
  avatar: text("avatar"),
  emailVerified: timestamp("emailVerified", { mode: "date" }),
  isAdmin: boolean("is_admin").default(false).notNull(),
});

export const accounts = pgTable("account", {
  userId: uuid("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  type: text("type").$type<AdapterAccountType>().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"),
});

export const sessions = pgTable("session", {
  sessionToken: text("sessionToken").primaryKey(),
  userId: uuid("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expires: timestamp("expires", { mode: "date" }).notNull(),
});
The isAdmin field enables role-based access control. By default, it’s set to false.

Session Management

Get Current Session

In Server Components:
import { auth } from "@/lib/auth";

export default async function Page() {
  const session = await auth();
  
  if (!session) {
    return <div>Please sign in</div>;
  }

  return (
    <div>
      <p>Welcome, {session.user.name}!</p>
      <p>Email: {session.user.email}</p>
      {session.user.isAdmin && <p>Admin Access</p>}
    </div>
  );
}
In API Routes:
apps/www/src/app/(protected)/api/example/route.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";

export async function GET() {
  const session = await auth();
  
  if (!session) {
    return NextResponse.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }

  return NextResponse.json({
    user: session.user,
  });
}

Client-Side Session

Use the UserContext provider:
import { useUser } from "@/hooks/use-user";

export function UserProfile() {
  const { user, loading } = useUser();

  if (loading) return <div>Loading...</div>;
  if (!user) return <div>Not signed in</div>;

  return (
    <div>
      <img src={user.avatar || user.image} alt={user.name} />
      <p>{user.name}</p>
      <p>{user.email}</p>
    </div>
  );
}

User Roles (isAdmin)

The isAdmin field provides basic role-based access control:

Set Admin Status

Manually update in database:
UPDATE users 
SET is_admin = true 
WHERE email = '[email protected]';
Or programmatically:
import { db } from "@feedback/db";
import { users } from "@feedback/db/schema";
import { eq } from "drizzle-orm";

await db
  .update(users)
  .set({ isAdmin: true })
  .where(eq(users.email, "[email protected]"));

Protect Admin Routes

In Server Actions:
import { adminActionClient } from "@/lib/actions/clients/admin";
import { z } from "zod";

export const deleteRequest = adminActionClient
  .metadata({ actionName: "deleteRequest", entity: "request" })
  .inputSchema(z.object({ id: z.string() }))
  .action(async ({ parsedInput, ctx: { user } }) => {
    // Only admins can access this
    // Middleware automatically checks user.isAdmin
    
    await db.delete(requests).where(eq(requests.id, parsedInput.id));
    return { success: true };
  });
In Pages:
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function AdminPage() {
  const session = await auth();
  
  if (!session?.user?.isAdmin) {
    redirect("/");
  }

  return <div>Admin Dashboard</div>;
}

Sign In/Sign Out

Sign In Button

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

export function SignInButton() {
  return (
    <form
      action={async () => {
        "use server";
        await signIn("google");
      }}
    >
      <button type="submit">Sign in with Google</button>
    </form>
  );
}

Sign Out Button

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

export function SignOutButton() {
  return (
    <form
      action={async () => {
        "use server";
        await signOut();
      }}
    >
      <button type="submit">Sign out</button>
    </form>
  );
}

Custom Pages

NextAuth is configured with custom pages:
pages: {
  signIn: "/login",      // Custom login page
  newUser: "/",           // Redirect for new users
}
Create a custom login page at app/login/page.tsx:
import { SignInButton } from "@/components/auth/sign-in-button";

export default function LoginPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="rounded-lg border p-8">
        <h1 className="mb-4 text-2xl font-bold">Sign In</h1>
        <p className="mb-4 text-gray-600">
          Sign in to access GTM Feedback
        </p>
        <SignInButton />
      </div>
    </div>
  );
}

Event Logging

NextAuth events are logged for debugging:
events: {
  async signIn({ user, account, isNewUser }) {
    console.log("[AUTH] Sign in:", {
      userId: user.id,
      email: user.email,
      provider: account?.provider,
      isNewUser,
      timestamp: new Date().toISOString(),
    });
  },
},
logger: {
  error(code, ...message) {
    console.error("[AUTH][ERROR]", code, message);
  },
},

TypeScript Types

Extend NextAuth types for custom fields:
apps/www/src/lib/auth.ts
import type { User as DbUser } from "@feedback/db/types";

declare module "next-auth" {
  interface Session {
    user: DbUser; // Includes id, avatar, isAdmin
  }

  interface User extends DbUser {}
}
This enables type-safe access to custom user fields:
const session = await auth();
session.user.isAdmin; // TypeScript knows this exists

Security Best Practices

Secure AUTH_SECRET

Use a cryptographically secure random string, never commit to version control

HTTPS in Production

Always use HTTPS for OAuth callbacks in production

Session Expiration

Configure appropriate session timeouts based on sensitivity

Token Storage

Tokens are stored securely in database, never in localStorage

Troubleshooting

Common Issues

Ensure the redirect URI in Google Console exactly matches:
http://localhost:3000/api/auth/callback/google
Check for trailing slashes and protocol (http vs https).
  1. Verify AUTH_SECRET is set
  2. Check database connection
  3. Ensure cookies are enabled
  4. Verify domain settings in production
The isAdmin field defaults to false. Manually update in database:
UPDATE users SET is_admin = true WHERE email = '[email protected]';

Database Schema

Learn about user and session tables

Server Actions

Protect actions with authentication middleware

Build docs developers (and LLMs) love