Appearance
Authentication & Billing
Authentication is Better Auth; billing is Polar; transactional email is Resend. Both billing and email sending are optional to configure individually - auth itself (email/password) always works out of the box.
Better Auth configuration
src/lib/auth.ts sets up the server-side auth instance:
- Adapter: Prisma (
prismaAdapter(prisma, { provider: "postgresql" })). - Email/password: always enabled, with
autoSignIn: true(signs the user in immediately after registering).sendResetPasswordis wired tosrc/lib/mailer.tsto email the "reset your password" link. - Email verification:
emailVerification.sendOnSignUp: truesends a verification email right after sign-up, viasendVerificationEmail(also wired tosrc/lib/mailer.ts). Verification is soft -requireEmailVerificationis intentionally left unset, so login is never blocked on it. Unverified users just see a badge + "resend" button in/settings(Account tab). - OAuth: Google and GitHub are added to
socialProvidersconditionally - a provider block only appears if both itsCLIENT_IDandCLIENT_SECRETenv vars are set, so leaving them unset just omits that provider from the login form. - Polar plugin: registered only when
isPolarEnabledis true (see below), wiringcheckout(product/slug list, success URL) andportal(customer self-service billing portal) sub-plugins. databaseHooks.user.create.after: fires after every new user is created. If Polar is enabled, it creates a matching Polar customer (externalId: user.id). If that Polar call fails, it rolls back by deleting the just-created Postgres user and rethrowing - sign-up either fully succeeds (Better Auth user + Polar customer) or fully fails, never half of one.
src/lib/auth-client.ts is the React client (authClient = createAuthClient({ plugins: [polarClient()] })), used by Client Components for sign-in/up/out, password reset (requestPasswordReset, resetPassword), email verification (sendVerificationEmail), profile updates (updateUser), password changes (changePassword), and reading Polar customer state (authClient.customer.state()).
Password reset & email verification
- Forgot/reset password:
/forgot-password(src/features/auth/forgot-password-form.tsx) callsauthClient.requestPasswordReset({ email, redirectTo: "/reset-password" }). The email link lands on/reset-password?token=...(src/features/auth/reset-password-form.tsx), which callsauthClient.resetPassword({ newPassword, token }). A missing/invalid token renders an "invalid link" state instead of the form. - Email verification: sent automatically on sign-up. Better Auth's own
/api/auth/...route verifies the token and redirects - no custom verify page is needed. Users can trigger a fresh one from/settingsviaauthClient.sendVerificationEmail()(seesrc/features/user/hooks/use-resend-verification-email.ts). - Change password (while logged in): the "Change password" form in
/settings(Account tab) callsauthClient.changePassword({ currentPassword, newPassword, revokeOtherSessions: false }). - Emails:
src/lib/mailer.tssends via Resend (src/lib/resend.ts) whenRESEND_API_KEY/EMAIL_FROMare set; otherwise it logs the recipient/subject/link to the console instead, so these flows are testable in local dev with zero Resend setup. Seeenvironment-variables.md. - Dashboard banner:
src/components/email-verification-banner.tsxreadsauthClient.useSession()directly (emailVerifiedis a base Better Auth session field, no extra query needed) and renders a non-dismissibleAlertwith a resend button for any unverified user. It's mounted once insrc/components/app-shell.tsx(insideSidebarInset, above{children}), so it appears on every(dashboard)route with no per-page wiring. - Gated checkout:
UpgradeToProButtoninsrc/features/auth/user-info-card.tsxcheckssession.user.emailVerifiedbefore callingauthClient.checkout(). Unverified users get aDialogexplaining verification is required, with its own resend action, instead of opening Polar checkout - unverified accounts should never be able to pay (failed receipt delivery, weaker fraud/chargeback and account-recovery guarantees).
src/lib/auth-utils.ts holds the server-side guards:
| Function | Behavior |
|---|---|
requireAuth() | Redirects to /login if there's no session. Used in (dashboard)/layout.tsx. |
requireUnauth() | Redirects to / if there is a session. Used in (auth) pages so logged-in users can't see login/signup. |
requireAuthAndPremium(redirectUrl) | Like requireAuth(), plus checks for an active Polar subscription and redirects if absent - skipped entirely when Polar is unconfigured. |
getSessionOnServer() | Returns the session (or null) without redirecting. |
requirePremiumUserFromRequest(req) | Same premium check as above, but throws a 401/403 Response instead of redirecting - for use in raw Next.js route handlers rather than Server Components. |
There's no middleware.ts in this project - all route protection goes through these functions, called explicitly in layouts/pages/routes.
OAuth providers
Google: Google Cloud Console → APIs & Services → Credentials → Create OAuth 2.0 Client ID. Set the authorized redirect URI to http://localhost:3000/api/auth/callback/google (swap the domain in production).
GitHub: GitHub → Settings → Developer Settings → OAuth Apps → New OAuth App. Set the callback URL to http://localhost:3000/api/auth/callback/github.
Then set the corresponding env vars - see environment-variables.md.
Polar billing
src/lib/polar.ts is the central "is billing configured" gate:
typescript
export const isPolarEnabled = !!(
env.POLAR_ACCESS_TOKEN && env.POLAR_SERVER && env.POLAR_SUCCESS_URL
)
export const polarClient = isPolarEnabled ? new Polar({ ... }) : nullEvery place that checks subscription status (premiumProcedure, requireAuthAndPremium, useHasActiveSubscription) checks isPolarEnabled first and grants access to everyone when it's false. This means the premium-gated demos (e.g. demo/posts' bulkPublish) work with zero Polar setup - useful for developing without a Polar account, but remember to actually configure Polar before shipping anything that should be paywalled.
Setup:
- Create an account at polar.sh
- Create a product (e.g. "Pro Plan")
- Copy its Product ID and replace
YOUR_PRODUCT_IDinsrc/lib/auth.ts(checkout({ products: [{ productId: "YOUR_PRODUCT_ID", slug: "pro" }] })) - Set
POLAR_ACCESS_TOKEN,POLAR_SUCCESS_URL, andPOLAR_SERVER(sandboxwhile testing) in.env
Premium subscription flow
- User signs up → Better Auth creates the
Userrow →databaseHooks.user.create.aftercreates a matching Polar customer. - User upgrades →
authClient.checkout({ slug: "pro" })opens the Polar checkout for the product registered under that slug. premiumProcedure(server) checks for an active subscription viapolarClient.customers.getStateExternal({ externalId: user.id }).useHasActiveSubscription()(client, fromsrc/features/subscriptions/hooks/use-subscription.ts) exposes the same check for UI gating - it first reads the publicuser.isPolarEnabledtRPC flag, and short-circuits to "everyone is premium" when Polar isn't configured.
Premium feature gating - usage
Server-side (tRPC):
typescript
myPremiumEndpoint: premiumProcedure.mutation(async ({ ctx }) => {
// ctx.customer is the Polar customer state (or null in mock mode)
})Client-side:
typescript
import { useHasActiveSubscription } from "@/features/subscriptions/hooks/use-subscription"
const { hasActiveSubscription } = useHasActiveSubscription()Trigger checkout:
typescript
import { authClient } from "@/lib/auth-client"
authClient.checkout({ slug: "pro" })