---
name: adding-email-link-sign-in
description: Use when a Next.js App Router site needs passwordless email sign-in ("magic link", "email link login", "sign in with email") with users stored in Postgres or Neon, especially on Next.js 16 with Cache Components.
---

# Adding email link sign-in

## Overview

Auth.js v5's email provider sends a single-use link. The Drizzle adapter keeps users, sessions and tokens in Neon Postgres. Every successful sign-in upserts the user row. The reference files next to this SKILL.md are a working implementation, proven end to end on three production sites. Copy them, then change the brand strings, colors and origin.

## Stack

`next-auth@5.0.0-beta.30`, `@auth/drizzle-adapter`, `drizzle-orm` + `drizzle-kit`, `@neondatabase/serverless` (neon-http), `nodemailer` over Amazon SES SMTP, `zod`. Session strategy `database`.

## Data shape

| Table                                         | Columns                                                                                                                  |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `users`                                       | Adapter columns (`id`, `name`, `email` unique, `emailVerified`, `image`) plus `createdAt`, `lastSignInAt`, `signInCount` |
| `accounts`, `sessions`, `verification_tokens` | Exactly as the adapter defines them                                                                                      |

`recordSignIn(email)` in `users.ts` is one `insert … on conflict (email) do update` that bumps `last_sign_in_at` and `sign_in_count`. It runs from `events.signIn`, so retries and races only ever count; the unique index means there is never a second row.

## Files

| Reference file                                         | Goes to                           | Role                                                                                      |
| ------------------------------------------------------ | --------------------------------- | ----------------------------------------------------------------------------------------- |
| `auth.ts`                                              | `src/auth.ts`                     | Provider, adapter, throttle callback, upsert event, pinned link origin                    |
| `signin-link.ts` + `signin-link.test.ts`               | `src/lib/`                        | Pure rules: throttle cutoff, email normalization, confirm link, confirm-target validation |
| `signin-email.ts`                                      | `src/lib/`                        | Plain-text and inline-styled HTML email                                                   |
| `schema.ts`, `users.ts`, `db-index.ts` (as `index.ts`) | `src/db/`                         | Tables, upsert, Neon client                                                               |
| `local-env.mjs`, `drizzle.config.ts`                   | repo root                         | Load `.config/<category>/*.env` into `process.env` for dev, build and migrations          |
| `signin-actions.ts`, `signin-page.tsx`                 | `src/app/signin/`                 | Form, server action, check-your-inbox and error states                                    |
| `confirm-page.tsx`                                     | `src/app/signin/confirm/page.tsx` | Button page between the email and the callback                                            |
| `account-page.tsx`                                     | `src/app/account/page.tsx`        | Session-gated page with sign-out                                                          |

Also add `src/app/api/auth/[...nextauth]/route.ts` (`export const { GET, POST } = handlers`), `src/config/site.config.ts` (`SITE_ORIGIN`), `src/config/email/smtp.config.ts` (host, port 587, sender), and a "Sign in" link in the header.

## Setup

1. Secrets live in git-ignored files: `.config/db/neon.env` (`NEON_URL`, pooled), `.config/email/aws-ses.env` (`SMTP_USER`, `SMTP_PASSWORD`), `.config/auth/auth.env` (`AUTH_SECRET` from `openssl rand -base64 32`). Add the same names to the Vercel project for production, preview and development.
2. Confirm the sender domain can send: `aws sesv2 get-email-identity --email-identity <domain> --region us-west-2` must show `VerifiedForSendingStatus: true`, and `get-account` must show production access.
3. `next.config.ts` and `drizzle.config.ts` call `loadLocalEnv()` first. Add `esbuild: true` under `allowBuilds` in `pnpm-workspace.yaml` for drizzle-kit.
4. `pnpm db:generate`, commit `drizzle/`, `pnpm db:migrate`.

## Decisions that matter

- **Confirm page.** Mail scanners prefetch links, and the callback signs in on a GET, so a prefetch would spend the token. The email links to `/signin/confirm?url=…`, which validates the callback is same-origin and on `/api/auth/callback/email`, and only its button continues.
- **Throttle in the `signIn` callback,** not the form action, so a direct POST to `/api/auth/signin/email` is throttled too. Returning a URL skips the send.
- **Pinned origin.** Auth.js builds the link from the `Host` header. In production (`VERCEL_ENV === "production"`) pass `SITE_ORIGIN` so a forged host can't mail a real token to someone else's domain.
- **`normalizeIdentifier`** lowercases and trims before the adapter, the throttle and the upsert see the address.

## Next.js 16 gotchas

- Call `await connection()` before `auth()` in a page. Otherwise prerendering hits Auth.js's `crypto.getRandomValues()` and dev throws.
- Read `searchParams` inside a `<Suspense>` child. The state streams in, so check the rendered result after it settles.
- A gated page under partial prerendering answers 200 and redirects in the stream (`NEXT_REDIRECT` plus a meta refresh). Its static heading flashes first; keep user data out of the static shell.
- `db-index.ts` gives Neon the unpatched `fetch`. Next caches fetches made while prerendering, POSTs included, which would replay old query results.
- Keep `auth()` off the homepage so it stays static.

## Verification without an inbox

Make a token by hand and walk the real flow in a browser:

```sh
raw=$(openssl rand -hex 32)
hash=$(printf '%s%s' "$raw" "$AUTH_SECRET" | sha256sum | cut -d' ' -f1)
psql "$NEON_URL" -c "insert into verification_tokens (identifier, token, expires) values ('e2e-check@example.invalid', '$hash', now() at time zone 'utc' + interval '20 minutes')"
# open /signin/confirm?url=<encoded …/api/auth/callback/email?callbackUrl=%2Faccount&token=$raw&email=…>
```

Check, in order: the token survives loading the confirm page; the button lands on `/account` with one user row, `sign_in_count` 1 and one session; sign-out deletes the session; reusing the link lands on `?error=Verification`; a second token gives `sign_in_count` 2 on the same row. Delete the test user afterwards. Send one real email to your own address to prove SES delivery.

## Common mistakes

| Mistake                                                | Fix                                                                                      |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| `source`-ing an env file with an unquoted `&` in a URL | It backgrounds the command and prints the secret. Read values with `sed -n 's/^KEY=//p'` |
| Testing the send with `@example.com`                   | SES bounces hurt the sender's reputation. Use a real inbox you own, once                 |
| Emailing the raw callback URL                          | Scanners burn the token. Link to the confirm page                                        |
| Trusting the request host for the link                 | Pin `SITE_ORIGIN` in production                                                          |
| Upserting from the `createUser` event                  | It doesn't fire on later sign-ins. Use `events.signIn`                                   |
