@bakaburg24/identity-ui
v0.7.0
Published
Drop-in auth for any Next.js app. One init command, one config file, full feature set: login / signup / forgot / reset / magic-link / OAuth / Solana wallet. Calls a self-hosted identity service (Rust); no Supabase / Clerk / Auth0 lock-in.
Downloads
272
Maintainers
Readme
@bakaburg24/identity-ui
Drop-in auth for Next.js apps. One init command, one config file, full feature set. Use it instead of Supabase Auth, Clerk, or Auth0 — until you're ready to migrate, or forever.
pnpm add @bakaburg24/identity-ui
npx @bakaburg24/identity-ui init
# done. /auth/login + /auth/signup + /auth/forgot + /auth/reset all work.That's the whole setup. Three files get scaffolded. You edit one of them to set your brand color and pick which auth methods to expose. Login, signup, forgot-password, password-reset, magic-link, OAuth (Google/Apple), Solana wallet sign-in — all wired and styled out of the box.
Why this exists
Most Next.js apps eventually need auth. The current options are:
- Supabase / Clerk / Auth0 — fast to integrate, but you're locked into their pricing, their policies, and their data plane. Hard to leave.
- NextAuth / Auth.js — flexible, but you write the UI yourself. Login forms, signup pages, forgot-password flows — all on you.
- Roll your own — full control, but you'll spend weeks rebuilding what others have already gotten right.
This package sits between them. The UI + the API plumbing live in npm, the auth backend is your own Rust service (source — Apache-2.0). Migrate to a managed provider whenever you want; you own the user data.
Feature parity table
| Feature | This package | NextAuth | Clerk | Supabase Auth | |---|---|---|---|---| | Email + password | ✅ | ✅ | ✅ | ✅ | | Forgot password (UI + flow) | ✅ | ❌ (DIY) | ✅ | ✅ | | Magic link | ✅ | ✅ | ✅ | ✅ | | OAuth (Google / Apple / etc.) | ✅ | ✅ | ✅ | ✅ | | Solana wallet sign-in | ✅ | ❌ | ❌ | ❌ | | Multi-tenant | ✅ | ⚠️ (via custom adapters) | ✅ (paid) | ⚠️ (RLS DIY) | | Self-hosted | ✅ | ✅ | ❌ | ⚠️ (paid tier) | | Brand-themable UI | ✅ | ❌ (DIY) | ✅ | ⚠️ | | Drop-in scaffold CLI | ✅ | ❌ | ❌ | ❌ | | Cost at 100k MAU | $0 + your infra | $0 | ~$1k/mo | ~$25/mo | | Cost at 1M MAU | $0 + your infra | $0 | ~$10k/mo | ~$600/mo | | Lock-in | None — your DB | None | High | Medium |
How auth gets added to a new Next.js app
0. Install + init
pnpm add @bakaburg24/identity-ui
npx @bakaburg24/identity-ui initThis creates four files:
lib/auth-config.ts— brand + methods + handler configlib/auth-client.ts— thin browser fetch wrapperapp/auth/[[...segment]]/page.tsx— UI catch-all (1-line re-export)app/api/auth/[...path]/route.ts— API catch-all (3 lines)
Idempotent — re-running skips files that already exist (--force to overwrite).
1. Mount the provider in your layout
// app/providers.tsx (or app/layout.tsx)
"use client";
import { IdentityUIProvider } from "@bakaburg24/identity-ui";
import "@bakaburg24/identity-ui/styles.css";
import { authConfig } from "../lib/auth-config";
export function Providers({ children }: { children: React.ReactNode }) {
return <IdentityUIProvider config={authConfig}>{children}</IdentityUIProvider>;
}2. Set env vars
IDENTITY_BASE_URL=https://identity.your-host.com
IDENTITY_TENANT_SLUG=your-tenantIDENTITY_BASE_URL points at the identity service — your own deploy or someone else's hosted instance. IDENTITY_TENANT_SLUG defaults to mailflix so override it.
3. Edit lib/auth-config.ts
Set your brand accent color, optional logo JSX, and which auth methods to expose:
import type { IdentityUIConfig } from "@bakaburg24/identity-ui";
import { authClient } from "./auth-client";
export const authConfig: IdentityUIConfig = {
client: authClient,
brand: {
name: "My App",
accent: "#6366f1",
// logo: <YourBrandMark />,
// tagline: "...",
},
methods: ["password", "oauth-google", "magic-link"],
defaultRedirect: "/dashboard",
};Done. Visit /auth/login, /auth/signup, /auth/forgot, /auth/reset?token=….
URL surface
The package owns these routes — no consumer code needed beyond the four scaffolded files.
| URL | View / Endpoint |
|---|---|
| /auth or /auth/login | LoginForm |
| /auth/signup | SignupForm |
| /auth/forgot | ForgotForm (sends reset email) |
| /auth/reset?token=... | ResetForm (token from email) |
| POST /api/auth/login | identity /auth/email/login + sets session cookies |
| POST /api/auth/signup | identity /auth/email/signup + sets session cookies |
| POST /api/auth/logout | identity /auth/logout + clears session cookies |
| POST /api/auth/refresh | identity /auth/refresh + rotates session cookies |
| GET /api/auth/me | current user (from cookie) |
| POST /api/auth/forgot | identity /auth/email/forgot |
| POST /api/auth/reset | identity /auth/email/reset |
Both /auth/* and /api/auth/* are catch-alls. Adding new flows in the package exposes them everywhere on the next pnpm install.
Auth methods
Toggle via the methods array in lib/auth-config.ts:
| Method | Description | Scaffolded? |
|---|---|---|
| password | Email + password (always available) | ✅ fully wired by init |
| magic-link | Passwordless email sign-in / sign-up | ✅ fully wired by init (v0.5.0+) |
| oauth-google | "Continue with Google" button | ✅ fully wired by init (v0.5.0+) |
| oauth-apple | "Continue with Apple" button | ⚠️ button only — needs an identity Apple provider (not built server-side yet) |
| wallet | Solana wallet sign-in | ⚠️ stub — implement signInWithWallet against your wallet-adapter flow |
password, magic-link, and oauth-google work with zero extra code — the scaffolded auth-client.ts + the catch-all handler implement them end to end against the identity service. wallet still needs a consumer-supplied signInWithWallet (wallet-adapter UI is app-specific). oauth-apple renders a button but identity has no Apple provider yet, so leave it out of methods until that ships.
Magic-link: how the two halves fit
magic-link is a two-step flow and the scaffold handles both:
- Request — the user enters their email on the sign-in page;
signInWithMagicLinkPOSTs to/api/auth/magic-link/request. Identity emails a one-shot link. Response is always 204 (no account enumeration). If the email is new, the account is created when the link is consumed — first link doubles as sign-up. - Consume — the emailed link points at
/auth/magic-link/<token>in your app. The scaffolded catch-all page reads the token and renders<MagicLinkConsume>, which auto-submits it to/api/auth/magic-link/consume. On success the session cookie is set andonAuthenticatedfires — same as a password login. No user input on the consume page; it just shows "signing you in…".
You don't write any of this — init scaffolds it. <MagicLinkConsume> is also exported standalone if you want a custom consume page (see Escape hatches).
Handling auth errors (account status, step-up)
The identity service uses prefix-coded error messages for conditions a login UI should treat specially. These come back with HTTP 400 and error.code === "BAD_REQUEST", but the error.message starts with a stable, machine-matchable prefix. The package surfaces both code and message through the thrown error (IdentityHandlerError server-side; your auth-client.ts sees the JSON body) — match on the message prefix, not the HTTP code.
| Message prefix | When | What your UI should do |
|---|---|---|
| ACCOUNT_SUSPENDED: | The operator suspended this user (e.g. lapsed subscription). Auth is blocked until reactivated. | Show "your access is suspended — contact <operator>", not a generic "wrong password". Don't offer retry. |
| ACCOUNT_DISABLED: | Harder stop (banned / closed). | Show "this account has been disabled". Terminal — no retry, no self-serve. |
| STEP_UP_REQUIRED: | Switching into a high-trust (operator-custody) tenant needs password re-entry. | Prompt for the password, retry switch-tenant with current_password set. |
| STEP_UP_BAD_PASSWORD: | Step-up password was wrong. | Inline "incorrect password, try again" on the step-up prompt. |
| STEP_UP_NO_PASSWORD: | Wallet/OAuth-only account can't satisfy step-up. | Tell them this tenant requires a password; link to set one. |
| INVITE_EMAIL_MISMATCH: / INVITE_REQUIRES_EMAIL: | Accepting a team invite with the wrong / no email. | The full message names both addresses — surface it verbatim. |
Why prefix-coded and not a distinct error.code: these are payload/state conditions layered on top of otherwise-valid input, so the HTTP status stays 400 and the stable code lives in the message prefix (the same convention the service uses for EMAIL_TAKEN, USERNAME_TAKEN). A robust login form does message.startsWith("ACCOUNT_SUSPENDED:") rather than switching on the HTTP code.
// in your login error handler
catch (e) {
const msg = e?.message ?? "";
if (msg.startsWith("ACCOUNT_SUSPENDED:") || msg.startsWith("ACCOUNT_DISABLED:")) {
showAccountBlocked(msg); // not "invalid credentials"
} else if (msg.startsWith("STEP_UP_REQUIRED:")) {
promptForPasswordThenRetrySwitch();
} else {
showGenericAuthError(msg);
}
}Switching tenants
A user who belongs to more than one tenant (e.g. an operator's staff who also has a personal account elsewhere) can mint a fresh session pinned to a different tenant via POST /v1/identity/auth/switch-tenant ({ target_tenant_slug }, optional current_password for step-up). The package does not scaffold a tenant-switcher UI — it's an operator-dashboard concern, not a consumer-app login concern. If your app needs it, call the endpoint directly through your auth-client.ts; the new token pair comes back in the standard { user, tokens } shape and you store it the same way as a login.
Server-side handler config
The handlerConfig exported from lib/auth-config.ts configures the API catch-all. Most fields default sensibly from env:
import type { HandlerConfig } from "@bakaburg24/identity-ui/next/handlers";
export const handlerConfig: HandlerConfig = {
// identityBaseUrl: defaults to process.env.IDENTITY_BASE_URL
// tenantSlug: defaults to process.env.IDENTITY_TENANT_SLUG, then "mailflix"
// cookieNames: defaults to { access: "mf_id_access", refresh: "mf_id_refresh" }
// cookieDomain: unset = host-only cookie. Set to ".example.com" for cross-subdomain.
// secure: defaults to NODE_ENV === "production"
// sameSite: defaults to "lax"
};Theming
Brand color flows through inline (no CSS-vars-on-root needed). For deeper theming, override these CSS variables in your stylesheet:
:root {
--siu-bg: #0b0f17;
--siu-surface: #11161f;
--siu-surface-2: #161c27;
--siu-line: rgba(148, 163, 184, 0.16);
--siu-line-strong: rgba(148, 163, 184, 0.3);
--siu-ink: #e2e8f0;
--siu-ink-muted: #94a3b8;
--siu-danger: #ef4444;
--siu-good: #22c55e;
--siu-radius: 8px;
}Defaults are dark-mode only today.
Escape hatches
Custom page chrome (e.g. tenant picker before the form)
Skip the /next re-export. Import <AuthFlow> directly and write your own page:
"use client";
import { AuthFlow, useIdentityUI } from "@bakaburg24/identity-ui";
import { useRouter, useSearchParams } from "next/navigation";
export default function CustomAuthPage({ params }) {
const config = useIdentityUI();
const router = useRouter();
const search = useSearchParams();
return (
<YourCustomShell>
<YourPreFormGate />
<AuthFlow
segment={...}
resetToken={search.get("token") ?? undefined}
redirectAfter={search.get("next")}
methods={config.methods}
client={config.client}
brand={config.brand}
onAuthenticated={(result, to) => {
config.onAuthenticated?.(result);
router.push(to ?? "/");
}}
/>
</YourCustomShell>
);
}Per-endpoint API customisation (e.g. extra rate limit on /forgot)
Skip the catch-all. Use the per-endpoint factories:
import {
createForgotHandler,
resolveConfig,
} from "@bakaburg24/identity-ui/next/handlers";
const cfg = resolveConfig(handlerConfig);
export const POST = withRateLimit(createForgotHandler(cfg), { rps: 1 });Individual form components
LoginForm, SignupForm, ForgotForm, ResetForm, MagicLinkForm, OauthRow, WalletRow — all exported. Compose any layout you want.
Backend: the identity service
This package is the client half. The server half is a Rust + Axum service that lives at services/identity/. The authoritative, always-current API surface is the OpenAPI spec the running service serves at GET /openapi.json — generate a client from that rather than trusting this list, which is a summary and can lag the service. Major groups:
- Auth:
/v1/identity/auth/email/{signup,login,verify,resend,forgot,reset,change-password} - OAuth:
/v1/identity/auth/oauth/{discord/start,discord/callback,exchange} - Wallet:
/v1/identity/auth/wallet/{nonce,verify}(Solana sign-message) - TOTP / 2FA: setup/enable/disable under
/v1/identity/me/2fa/*; the mid-login second-factor step isPOST /v1/identity/auth/challenge - Sessions:
/v1/identity/auth/{refresh,logout,switch-tenant} - User profile:
/v1/identity/me/* - Team management (operator dashboard, not this package):
/v1/identity/tenants/:slug/team/*— invites, members, role changes, ownership transfer - End-user admin (operator dashboard, not this package):
/v1/identity/tenants/:slug/admin/users/*— list, credit, suspend/disable/reactivate (/status) - Multi-tenant ops:
/v1/identity/admin/{tenants,operator-keys} - Operator integration:
/v1/identity/auth/token-exchange(federated identity — operator backend mints a user+session for its own customer) - Public:
/v1/identity/.well-known/jwt-public-key(RS256 verification key) and/openapi.json(full spec)
You can deploy the identity service to Railway / Fly / your own infra; the package doesn't care about location.
Multi-tenant model
The identity service is multi-tenant by design — one Postgres schema, every table has tenant_id. Each app (or product, or organisation) is a tenant; tenants share the deployment but have isolated user pools.
Real example: this package is used by mailflix (one tenant, ~6 apps) and solana-shuffle (one tenant, ~2 apps) — two tenants share the same identity service. New tenants come online via the operator-signup flow without a redeploy.
Versioning
Semver. v0.x means breaking changes can land on minor bumps — pin to an exact version until 1.0.
| Version | Highlights |
|---|---|
| 0.1.0 | Initial: components only; consumer wrote a 30-line catch-all page. |
| 0.2.0 | Breaking. Added IdentityUIProvider + /next page subpath. Consumer becomes 1-line page + 1 provider wrap. |
| 0.3.0 | Breaking. Added /next/handlers sub-export + scaffold CLI. Consumer is now 1 init command + 1 config file. |
| 0.3.1 | Patch. Inlined RouteContext type so consumer next build doesn't TS4023. |
| 0.4.0 | Breaking. Renamed scope @mailflix/identity-ui → @bakaburg24/identity-ui. License: MIT. |
| 0.4.1 | Patch. No public API change (semver-patch; safe in-place bump from 0.4.0). |
| 0.5.0 | Magic-link + Google OAuth now real. Both were previously UI-only shells with no backend. Now end-to-end: signInWithMagicLink + new consumeMagicLink client methods, a magic-link AuthFlow segment + exported <MagicLinkConsume>, oauth-google start/exchange handlers. init scaffolds working implementations for all three (password, magic-link, oauth-google) — no consumer code. New optional AuthClient.consumeMagicLink + AuthFlowProps.magicLinkToken (additive; existing consumers unaffected unless they enable magic-link). |
| 0.6.0 | Email-verification confirm flow. New verify AuthFlow segment + exported <VerifyConsume> (auto-submits the token from the /auth/verify/<token> email link, shows success, bounces to login — no session, like reset). New optional AuthClient.verifyEmail + resendVerification, new AuthFlowProps.verifyToken. Dispatcher gains email/verify + email/resend routes; init scaffolds both client methods. Backend verification email template now links to /auth/verify/<token> (path-param, package-router-compatible) instead of the old query-string form. Additive — existing consumers unaffected unless they surface verification. |
If you're on @mailflix/[email protected], swap to @bakaburg24/[email protected]+ — the API is additive otherwise.
Note: the backend identity service has a few features the package's scaffolded UI still doesn't surface (account suspend/disable, team management, switch-tenant). Those are service-side and reachable through the documented endpoints today. The error-handling section above is the one piece a current consumer should act on — a login form built before account-status existed will show "bad request" instead of "your access is suspended" until it matches the
ACCOUNT_SUSPENDED:prefix. (As of v0.5.0 magic-link and Google OAuth ARE fully scaffolded — they're no longer in this gap.)
What's not in here yet
The identity service supports these; the package doesn't scaffold UI for them yet. Until it does, wire them through your own auth-client.ts against the documented endpoints.
- TOTP / 2FA challenge UI (planned). Backend ready:
POST /v1/identity/auth/challengeis the mid-login second factor; setup/disable live under/v1/identity/me/2fa/*. - Email-verification confirm page (
/auth/verify?token=...). Backend ready:POST /v1/identity/auth/email/verify, andPOST /v1/identity/auth/email/resendto re-send an expired link. (Note: magic-link sign-in already marks the email verified as a side effect, so a verified email is often achieved without this page.) - Apple OAuth — the package renders the button, but identity has no Apple provider yet. Don't put
oauth-appleinmethodsuntil it ships. (Google IS done as of v0.5.0.) - Sign-out button as a primitive — most consumers wire their own header.
- Tenant-switcher / team-management UI — operator-dashboard concerns, intentionally out of scope for a consumer auth package (the identity-portal app owns these).
Development
cd packages/identity-ui
pnpm install
pnpm typecheck
pnpm buildLicense
MIT — see LICENSE. Use it commercially. Modify it. Redistribute it.
Status
Early — pre-1.0, breaking changes on minor bumps. Used in production by 8+ apps across two product lines. If you're building something new, the value is real; if you're migrating from Supabase/Clerk, the cost is rewriting your lib/auth-client.ts once.
If you ship something with this package — drop a note, I'd love to see it.
