npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@naeemba/next-starter

v0.10.1

Published

Opinionated Next.js + Drizzle + Better Auth starter, shipped as a versioned package.

Readme

@naeemba/next-starter

Opinionated Next.js + Drizzle + Better Auth starter, shipped as a versioned npm package instead of a clone-and-fork template. Add it as a dependency, set env vars, create a few shim files, and you have working magic-link email sign-in. Bump the package version to pull in fixes.

If you're upgrading, see UPGRADING.md.

Sign-in methods

| Method | Enable via | Required env | | ---------- | --------------------------------------------------- | ------------------------------------------- | | Magic link | Default (or createAuth({ magicLink: {...} })) | RESEND_API_KEY in production | | Google | createAuth({ google: {} }) | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | | Passkey | createAuth({ passkey: { rpName: 'Your App' } }) | none (uses BETTER_AUTH_URL) |

Each method is opt-in. Enabling one does not require the others.

Install

npm install @naeemba/next-starter

Then scaffold the required shim files automatically:

npx @naeemba/next-starter init

Or skip the CLI and create them by hand (see Setup files in your app).

Peer dependencies: next >= 16, react >= 19, react-dom >= 19. Node >= 20.

Env vars

DATABASE_URL=postgres://user:pass@host:5432/db
BETTER_AUTH_SECRET=<32+ char random string>   # openssl rand -hex 32
BETTER_AUTH_URL=https://app.example.com
[email protected]                    # optional in dev, required for Resend in prod
RESEND_API_KEY=...                             # optional — when unset, magic links log to stdout
# Optional: NEXT_PUBLIC_BETTER_AUTH_URL — only set when the public URL the
# browser must call differs from window.location.origin (e.g. a proxy in
# front with a different hostname). Otherwise the client derives it at runtime.
# Note: postgres, @react-email/*, @better-auth/passkey, and resend are optional
# peer dependencies. Install only the ones you actually use — see UPGRADING.md.

Setup files in your app

lib/auth.ts

import { createAuth } from "@naeemba/next-starter/auth"
export const auth = await createAuth()

createAuth is async (since 0.7.0) so it can import() ESM-only optional peers like @better-auth/passkey. Top-level await resolves once at module init in Next 16 server modules; downstream importers see auth as a resolved Auth instance, not a Promise.

createAuth accepts options for magicLink (custom expiry, allowlist, custom email template), session (override session cookie / expiry settings), google, passkey, singleAdmin (lock sign-in to one or more emails), accountLinking, rateLimit (better-auth's rate-limit knob; BETTER_AUTH_RATE_LIMIT_DISABLED=1 env force-disables for local dev), and transport (BYO email delivery — replaces the built-in Resend/console dispatch for magic-link mail).

passkey also forwards registration and authentication to the underlying plugin, so you can opt into WebAuthn extensions. To enable the PRF extension (lets a passkey derive a stable client-side secret, e.g. for wrapping an encryption key):

createAuth({ passkey: { rpName: "Your App", registration: { extensions: { prf: {} } } } })

registration.extensions / authentication.extensions are typed against the standard AuthenticationExtensionsClientInputs, so this type-checks without @better-auth/passkey installed.

lib/auth-client.ts

"use client"
import { createAuthClient } from "@naeemba/next-starter/client"
import { passkeyClient } from "@better-auth/passkey/client"
export const authClient = createAuthClient({ passkey: passkeyClient })
export const { signIn, signOut, useSession } = authClient

Drop the passkeyClient import (and the passkey: field) to skip passkey support — the consumer bundle then excludes @better-auth/passkey entirely.

baseURL resolution: opts.baseURLNEXT_PUBLIC_BETTER_AUTH_URLwindow.location.origin. For same-origin deployments you can drop both the env var and the option. Set one only when the public URL the client must call differs from what the browser sees.

lib/auth-server.ts

import { createServer } from "@naeemba/next-starter/server"
import { auth } from "./auth"
export const { getSession, requireSession } = createServer(auth)

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

import { createAuthRoute } from "@naeemba/next-starter/auth-route"
import { auth } from "@/lib/auth"
export const { GET, POST } = createAuthRoute(auth)

app/sign-in/page.tsx

import { SignInPage } from "@naeemba/next-starter/pages/sign-in"
import { authClient } from "@/lib/auth-client"
export default function Page() {
  return <SignInPage authClient={authClient} errorCallbackUrl="/sign-in/error" />
}

SignInPage reads ?callbackUrl= from the URL query string and forwards it as the post-sign-in redirect (falling back to the callbackUrl prop, then "/"). Cross-origin and protocol-relative values are dropped silently to prevent open-redirect abuse. Set callbackParam to use a different query name. Set errorCallbackUrl to redirect to a friendly page when the magic-link verify endpoint fails — see the SignInErrorPage recipe below.

app/sign-in/error/page.tsx

import { SignInErrorPage } from "@naeemba/next-starter/pages/sign-in"
export default function Page() {
  return <SignInErrorPage />
}

Renders a heading + user-friendly message based on the ?error=<code> query the magic-link verify endpoint redirects to on failure (expired token, used token, etc).

Auth tables (no db/schema.ts or drizzle.config.ts needed for auth)

Auth tables are package-owned — apply them with npx next-starter migrate (see First-time setup below). init no longer scaffolds db/schema.ts or drizzle.config.ts for auth.

When you add your own tables, set up drizzle.config.ts and a schema file for them yourself. For an FK to the auth user, import it from the package: import { user } from "@naeemba/next-starter/schema".

First-time setup

The package owns the auth-table migrations. Apply them with:

npx next-starter migrate

That creates the user, session, account, verification, and passkey tables and their indexes, recorded in a __next_starter_migrations journal. Re-run after a package update whose release notes mention a schema change — it is idempotent.

The package owns the auth tables; you do not manage them with your own drizzle-kit. When you add your own tables, set up drizzle.config.ts + drizzle-kit for those — a fully independent track with its own __drizzle_migrations journal. For an FK to user, import it from the package: import { user } from "@naeemba/next-starter/schema".

Deploy ordering

Run the package's auth migrations before start, and before a build that reads the DB during static rendering:

{
  "scripts": {
    "prebuild": "next-starter migrate",
    "build": "next build",
    "prestart": "next-starter migrate",
    "start": "next start"
  }
}

next-starter migrate is idempotent, so steady-state deploys take a no-op hit. The build/start container needs DATABASE_URL. If nothing on a static route touches the DB, prestart alone is enough.

Once you add your own tables, chain your app migrate after the auth one so a FK to user(id) resolves: "prestart": "next-starter migrate && drizzle-kit migrate".

Enabling Google sign-in

// lib/auth.ts
export const auth = await createAuth({
  google: {
    // clientId / clientSecret default to env GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
    allowlist: (profile) => profile.email.endsWith("@acme.com"), // optional
  },
})

createAuth({ google }) auto-enables account linking with Google as a trusted provider (verified-email gated). Opt out with accountLinking: false.

Render the button:

<SignInForm authClient={authClient} google />

Locking sign-in to one or more emails

For solo apps or admin tools, use the singleAdmin shortcut:

await createAuth({
  singleAdmin: "[email protected]",          // or ["[email protected]", "[email protected]"]
  google: { /* clientId/secret from env */ },
})

singleAdmin auto-fills magicLink.allowlist and google.allowlist with a case-insensitive exact match. Google additionally rejects sign-in if the OAuth profile's email isn't verified. If you also pass an explicit magicLink.allowlist or google.allowlist, the explicit callback wins for that provider.

Enabling passkeys

// lib/auth.ts
export const auth = await createAuth({
  passkey: { rpName: "Your App" },  // rpID and origin default from BETTER_AUTH_URL
})

The passkey table is part of the package-owned auth schema — it is created by npx next-starter migrate (covered in First-time setup).

Render the sign-in button:

<SignInForm authClient={authClient} passkey />

The button is hidden silently in browsers without WebAuthn support.

Add a registration page (the init CLI scaffolds this automatically when --passkey is enabled — the default):

// app/account/passkeys/page.tsx
import { PasskeyManagerPage } from "@naeemba/next-starter/pages/passkey-manager"
import { authClient } from "@/lib/auth-client"

export default function Page() {
  return <PasskeyManagerPage authClient={authClient} />
}

PasskeyManagerPage is the chrome-wrapped variant (heading + description + main wrapper, parallel to SignInPage). Use the lower-level PasskeyManager directly if you want to drop the "Add a passkey" button into existing settings UI.

Reading the session in a Server Component

import { requireSession } from "@/lib/auth-server"

export default async function Page() {
  const { user } = await requireSession()
  return <div>Signed in as {user.email}</div>
}

Use getSession instead of requireSession if you want to handle the unauthenticated case yourself (it returns null rather than redirecting).

Common UX recipes

Sign out

"use client"
import { authClient } from "@/lib/auth-client"
import { useRouter } from "next/navigation"

export function SignOutButton() {
  const router = useRouter()
  return (
    <button
      type="button"
      onClick={async () => {
        await authClient.signOut()
        router.push("/sign-in")
        router.refresh()  // clears server-component sessions
      }}
    >
      Sign out
    </button>
  )
}

authClient.signOut() clears the better-auth session cookie. router.refresh() is what tells server components to re-read the session — without it, the user appears signed in until the next navigation.

Magic-link error pages

Set errorCallbackUrl="/sign-in/error" on SignInPage. When the verify endpoint fails, better-auth redirects to that URL with ?error=<code>. Pair with <SignInErrorPage/> for friendly copy. Override codes with errorMessages:

<SignInErrorPage
  errorMessages={{ EXPIRED_TOKEN: "Your link timed out. Request a new one." }}
/>

Rate limits

await createAuth({
  rateLimit: { window: 60, max: 5 },  // shorter window / lower max than the prod default
})

Pass rateLimit: false to disable entirely, or export BETTER_AUTH_RATE_LIMIT_DISABLED=1 to force-disable for local dev (the env var is overridden by an explicit { enabled: true }).

BYO email transport

Skip the built-in Resend dispatch entirely — use your existing email wrapper:

import { sendEmail as mySendEmail } from "@/lib/email"

await createAuth({
  transport: async ({ to, from, subject, text, html }) => {
    await mySendEmail({ to, from, subject, text, html })
  },
})

The transport receives the fully rendered email (subject, html, text). RESEND_API_KEY is not needed when transport is set. allowlist still gates ahead of transport — rejected addresses never reach your function.

Custom callbackUrl query param

<SignInPage authClient={authClient} callbackParam="next" />

Pair with createProxy({ callbackParam: "next" }) so the proxy → sign-in roundtrip uses the same query name end-to-end.

Protecting routes with proxy.ts

Next 16 renamed middleware.tsproxy.ts and middleware()proxy(). This package targets Next ≥ 16, so only the proxy form ships:

// proxy.ts (project root)
import { createProxy } from "@naeemba/next-starter/proxy"

export default createProxy({
  protect: ["/admin/:path*", "/dashboard/:path*"],
  signInPath: "/sign-in",         // default
})

export const config = { matcher: ["/((?!_next/|favicon.ico|api/auth/).*)"] }

The helper checks for the better-auth session cookie's presence — it does not validate the session against the database (that would require Node runtime; the Edge runtime can't reach Postgres). Unauthenticated requests are redirected to signInPath?callbackUrl=<original>. The real auth gate stays at the server-component level via requireSession().

Custom proxy.ts

If you already have a proxy.ts doing other work (host canonicalization, geo gating, A/B routing), import the cookie helper directly instead of wrapping createProxy:

import { type NextRequest, NextResponse } from "next/server"
import { getSessionCookie } from "@naeemba/next-starter/proxy"

export function proxy(req: NextRequest) {
  if (req.nextUrl.pathname.startsWith("/admin") && !getSessionCookie(req)) {
    return NextResponse.redirect(new URL("/sign-in", req.url))
  }
  // your other concerns ...
  return NextResponse.next()
}

Dev experience

Email delivery

EMAIL_TRANSPORT selects the built-in provider:

| EMAIL_TRANSPORT | Delivery | Required env | | ----------------- | -------- | ------------ | | (unset) | Auto: Resend if RESEND_API_KEY is set, else console | — | | resend | Resend HTTPS API | RESEND_API_KEY | | postal | Self-hosted Postal HTTPS API | POSTAL_API_URL, POSTAL_API_KEY | | console | Logs to the server console (dev) | — |

All providers use EMAIL_FROM as the sender. A custom transport passed to createAuth / sendEmail overrides this selection entirely.

Postal: point POSTAL_API_URL at your Postal server (e.g. https://postal.example.com) and set POSTAL_API_KEY to a server API credential. Delivery uses the Postal HTTPS API — no SMTP client is added.

If RESEND_API_KEY is unset, the magic link is written to your server logs in a line that looks like:

[magic-link-log] [email protected] url=http://localhost:3000/api/auth/magic-link/verify?token=...

Copy-click the URL to sign in. This is useful for local dev before you have a Resend account.

If NODE_ENV=production and RESEND_API_KEY is unset, a warning is printed at boot: magic links going to logs in prod means anyone with log access can sign in as any user.

TypeScript

This package is ESM-only with subpath exports. Your consumer tsconfig.json must set moduleResolution to "bundler" (Next 14+ default), "node16", or "nodenext". The legacy "node" resolution silently ignores subpath types conditions and imports degrade to any.

What ships in this package

| Subpath | What it is | |---|---| | @naeemba/next-starter/auth | createAuth() factory | | @naeemba/next-starter/client | createAuthClient() factory | | @naeemba/next-starter/auth-route | createAuthRoute(auth) — returns GET, POST handlers | | @naeemba/next-starter/schema | Drizzle table definitions | | @naeemba/next-starter/db | Lazy Drizzle client | | @naeemba/next-starter/email | sendMagicLink({ to, url }) | | @naeemba/next-starter/pages/sign-in | SignInForm (headless), SignInPage (with chrome), SignInErrorPage (friendly magic-link error UI). Supports google, passkey, magicLink props; reads ?callbackUrl= from the URL with open-redirect defense. | | @naeemba/next-starter/pages/passkey-manager | PasskeyManager (button only) + PasskeyManagerPage (with chrome) — "Add a passkey" UI for settings pages | | @naeemba/next-starter/server | createServer(auth) — returns getSession, requireSession | | @naeemba/next-starter/proxy | createProxy Edge-safe helper for redirecting unauthenticated traffic to your sign-in page (Next 16 proxy.ts convention). Also re-exports getSessionCookie for custom proxies. |

Design and rationale

This is a versioned npm package, not a clone-and-fork template. Consumers depend on it like any other package, set env vars, and create a handful of re-export shim files (lib/auth.ts, lib/auth-client.ts, etc.) that import from the package's subpath exports. Fixes flow through a ^ bump, not a manual diff against your fork.

The re-export shim pattern is deliberate: it keeps the package's surface minimal (no client/server entry confusion at the Next.js level) while letting consumers customize per-app concerns (createAuth({ google, passkey, magicLink: { allowlist } })) in code they own.

Styling

<SignInForm/>, <SignInPage/>, <SignInErrorPage/>, <PasskeyManager/>, and <PasskeyManagerPage/> ship with minimal inline styles (plain HTML attributes) — no CSS file, no Tailwind classes, no styled-components dependency.

For one-off targeting, every component takes a className prop. For full restyling (Tailwind, shadcn, your design system), use classNames:

<SignInPage
  authClient={authClient}
  classNames={{
    main: "min-h-screen flex items-center justify-center bg-background",
    heading: "text-3xl font-bold tracking-tight",
    submitButton: "btn btn-primary w-full",
    googleButton: "btn btn-outline w-full",
    emailInput: "input input-bordered w-full",
    emailLabel: "text-sm font-medium",
    error: "text-sm text-destructive mt-1",
  }}
/>

When a classNames.X key is set, the corresponding inline-style default is dropped for that element — your CSS becomes the single source of truth without !important. Unset keys keep the built-in defaults so you can override piecemeal.

Form keys: root, googleButton, passkeyButton, divider, dividerLine, dividerLabel, emailLabel, emailInput, submitButton, error, sentMessage. Page adds: main, heading, description.

For complete control, the shipped page is intentionally minimal — copy app/sign-in/page.tsx and call authClient.signIn.magicLink / social / passkey directly.

License

MIT — see LICENSE.