DEV Community

Cover image for Building a Production-Ready Auth System with Next.js 16, Better Auth, and TanStack Form
Abdullah Jan
Abdullah Jan

Posted on

Building a Production-Ready Auth System with Next.js 16, Better Auth, and TanStack Form

This is my first dev.to post. I'm starting my journey of building and learning in public.

It was 11 PM on a Tuesday. I had coffee #2 in hand, three browser tabs of Stack Overflow open, and a React Email component that refused to render. The error message mocked me: "Failed to render React component."

This is the story of how I built the authentication system for Carific.ai, my open-source, AI-powered career development platform, and every rabbit hole I fell into along the way.

The entire codebase is MIT licensed. Every line of code in this post is live in the repo. Fork it, learn from it, improve it. That's the whole point.

The Mission

I needed a complete auth system:

  • ✉️ Email/password authentication with Better Auth
  • Email verification that actually lands in inboxes (not spam)
  • 📝 Type-safe forms with TanStack Form's new composition pattern
  • 🔒 Server-side auth checks using Next.js App Router
  • 🧩 Clean architecture separating server and client concerns

Sounds straightforward, right? Spoiler: it really wasn't.


The Stack (Actual Versions from My package.json)

{
  "next": "16.0.7",
  "react": "19.2.1",
  "better-auth": "1.4.5",
  "@tanstack/react-form": "1.27.1",
  "@react-email/components": "1.0.1",
  "resend": "6.5.2",
  "zod": "4.1.13"
}
Enter fullscreen mode Exit fullscreen mode

Yes, I'm running Next.js 16 and React 19. Living on the edge. No regrets (mostly).


The Journey: 30 Commits Later

Before diving into the code, here's how this feature evolved. Building in public means showing the messy middle, not just the polished end result.

01c1695 Add authentication system with better-auth and Prisma
5ed22b0 Add initial Prisma migration for authentication database schema
a6fb7ba Add email verification system with Resend integration
4cae5f0 Refactor email system with Tailwind styling
8cdd67e Refine email template styling with inline styles (Outlook fix!)
c245401 Add authentication pages with sign in, sign up, and dashboard
54b2338 Refactor auth forms to use custom form hook
8f85a04 Refactor form components into separate modules
41b3005 Add remember me checkbox to sign-in form
e2643f2 Refactor auth pages to use shared AuthCard component
52cbe41 Refactor auth pages to server components (the big one!)
72c3393 Replace hardcoded route strings with centralized ROUTES constants
f6dcd9d Add welcome page for email verification flow
Enter fullscreen mode Exit fullscreen mode

Notice the pattern? Build → Realize it's wrong → Refactor → Repeat.

That's 30 commits across 7 pull requests just for authentication. Each refactor taught me something. Let me share the biggest lessons.


Chapter 1: The Email That Wouldn't Send

11:47 PM - The Error That Started It All

When I first wired up React Email with Resend, I followed what seemed like the obvious pattern:

// ❌ This doesn't work
await resend.emails.send({
  from: process.env.EMAIL_FROM,
  to: email,
  subject: "Verify your email",
  react: <EmailVerification verificationUrl={url} />,
});
Enter fullscreen mode Exit fullscreen mode

The error: Failed to render React component. Make sure to install @react-email/render or @react-email/components.

I checked my package.json. The packages were there. I ran npm ls @react-email/components. Installed. I cleared .next, restarted the dev server, and sacrificed a rubber duck to the debugging gods.

Nothing.

12:23 AM - The Breakthrough

After an embarrassing amount of Googling, I found it. The react prop in Resend doesn't play nice with Next.js server components. You need to manually render the component to HTML first:

// ✅ The fix that saved my sanity
import { render } from "@react-email/components";

const html = await render(
  <EmailVerification userName={userName} verificationUrl={url} />
);

await resend.emails.send({
  from: process.env.EMAIL_FROM!,
  to: email,
  subject: "Verify your email address",
  html, // Pass the rendered HTML string, not the component
});
Enter fullscreen mode Exit fullscreen mode

One line. That's it. The render() function converts your React Email component to an HTML string. Two hours of my life for one function call.

If this saves you even 10 minutes, this blog post was worth writing.

The Outlook Problem (Because Of Course There's More)

With emails finally sending, I opened React Email's dev server to admire my work. Yellow warnings everywhere:

⚠️ border-radius - Not supported in Outlook
⚠️ text-decoration-line - Not supported in Outlook
⚠️ word-break - Not supported in Outlook, Yahoo! Mail
Enter fullscreen mode Exit fullscreen mode

Outlook. The Internet Explorer of email clients. Still haunting us in 2024.

My pragmatic solution: Inline styles for the critical stuff, graceful degradation for the rest.

<Button
  href={verificationUrl}
  className="rounded-md bg-primary px-6 py-3 text-white"
  style={{ textDecoration: "none" }} // Inline style for Outlook
>
  Verify Email
</Button>
Enter fullscreen mode Exit fullscreen mode

Will Outlook users see square buttons? Yes. Will they still be able to verify their email? Also yes. Ship it.


Chapter 2: TanStack Form Composition (Where Things Got Fun)

After the email saga, I needed a win. Enter TanStack Form v1 and its new createFormHook API.

I'd used TanStack Form before, but always ended up with repetitive boilerplate, the same label/error/input pattern copy-pasted across every form. The composition pattern changes everything.

Step 1: Create Form Contexts

// hooks/form-context.tsx
import { createFormHookContexts } from "@tanstack/react-form";

export const { fieldContext, useFieldContext, formContext, useFormContext } =
  createFormHookContexts();
Enter fullscreen mode Exit fullscreen mode

Step 2: Build a Base Field Wrapper

This handles labels, descriptions, and error states for all field types:

// components/form/form-base.tsx
"use client";

import { ComponentProps, ReactNode } from "react";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field";
import { useFieldContext } from "@/hooks/form-context";

export type FormControlProps = {
  label: string;
  description?: string;
};

type FormBaseProps = FormControlProps & {
  children: ReactNode;
  orientation?: ComponentProps<typeof Field>["orientation"];
  controlFirst?: boolean;
};

export function FormBase({
  children,
  label,
  description,
  orientation,
  controlFirst,
}: FormBaseProps) {
  const field = useFieldContext();

  const isInvalid =
    field.state.meta.errors.length > 0 && field.state.meta.isTouched;

  const labelElement = (
    <>
      <FieldLabel htmlFor={field.name}>{label}</FieldLabel>
      {description && <FieldDescription>{description}</FieldDescription>}
    </>
  );

  return (
    <Field orientation={orientation} invalid={isInvalid}>
      {controlFirst ? (
        <>
          <FieldContent>{children}</FieldContent>
          {labelElement}
        </>
      ) : (
        <>
          {labelElement}
          <FieldContent>{children}</FieldContent>
        </>
      )}
      <FieldError />
    </Field>
  );
}
Enter fullscreen mode Exit fullscreen mode

🚨 The Gotcha That Cost Me 30 Minutes

I initially used useFormContext() here. Bad idea. The error message was... unhelpful:

formContext only works when within a formComponent passed to createFormHook
Enter fullscreen mode Exit fullscreen mode

Here's the mental model that finally clicked:

  • fieldComponents → Use useFieldContext() (you're inside a field)
  • formComponents → Use useFormContext() (you're at the form level)

FormBase wraps individual fields, so it needs useFieldContext(). Simple once you know it, maddening until you do.

Step 3: Create Pre-bound Field Components

// components/form/form-fields.tsx
"use client";

import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useFieldContext } from "@/hooks/form-context";
import { FormBase, FormControlProps } from "./form-base";

interface TextFieldProps extends FormControlProps {
  placeholder?: string;
  type?: "text" | "email" | "password";
  autoComplete?: string;
}

export function TextField({
  label,
  description,
  placeholder,
  type = "text",
  autoComplete,
}: TextFieldProps) {
  const field = useFieldContext<string>();

  return (
    <FormBase label={label} description={description}>
      <Input
        id={field.name}
        name={field.name}
        type={type}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
        placeholder={placeholder}
        autoComplete={autoComplete}
        aria-invalid={
          field.state.meta.errors.length > 0 && field.state.meta.isTouched
        }
      />
    </FormBase>
  );
}

export function CheckboxField({ label, description }: FormControlProps) {
  const field = useFieldContext<boolean>();

  return (
    <FormBase
      label={label}
      description={description}
      orientation="horizontal"
      controlFirst // Checkbox before label
    >
      <Checkbox
        id={field.name}
        checked={field.state.value}
        onCheckedChange={(checked) => field.handleChange(checked === true)}
        onBlur={field.handleBlur}
      />
    </FormBase>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Wire It All Together

// hooks/form.tsx
import { createFormHook } from "@tanstack/react-form";
import { fieldContext, formContext } from "./form-context";
import { TextField, CheckboxField } from "@/components/form/form-fields";
import { SubmitButton } from "@/components/form/form-components";

export const { useAppForm, withForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: {
    TextField,
    CheckboxField,
  },
  formComponents: {
    SubmitButton,
  },
});
Enter fullscreen mode Exit fullscreen mode

The Payoff: Forms That Don't Make Me Cry

Remember the boilerplate I mentioned? Gone. Now my sign-in form looks like this:

const form = useAppForm({
  defaultValues: {
    email: "",
    password: "",
    rememberMe: false,
  },
  validators: { onSubmit: signInSchema },
  onSubmit: async ({ value }) => {
    // Handle sign in
  },
});

return (
  <form
    onSubmit={(e) => {
      e.preventDefault();
      form.handleSubmit();
    }}
  >
    <form.AppField name="email">
      {(field) => (
        <field.TextField
          label="Email"
          type="email"
          placeholder="you@example.com"
        />
      )}
    </form.AppField>

    <form.AppField name="password">
      {(field) => (
        <field.TextField
          label="Password"
          type="password"
          placeholder="••••••••"
        />
      )}
    </form.AppField>

    <form.AppField name="rememberMe">
      {(field) => <field.CheckboxField label="Remember me" />}
    </form.AppField>

    <form.AppForm>
      <form.SubmitButton label="Sign in" loadingLabel="Signing in..." />
    </form.AppForm>
  </form>
);
Enter fullscreen mode Exit fullscreen mode

Every field is type-safe. Validation is declarative. Adding a new form takes minutes, not hours.

This is the kind of DX that makes you excited to build features instead of dreading them.


Chapter 3: The Server/Client Dance

Next.js App Router gives us a superpower: server components. But with great power comes great confusion about what goes where.

Here's the architecture I landed on after several refactors:

Server Component (Page)

// app/(auth)/signin/page.tsx
import { Metadata } from "next";
import { AuthCard } from "@/components/auth/auth-card";
import { SignInForm } from "@/components/auth/signin-form";
import { checkAuthAndRedirect } from "@/lib/auth-check";

export const metadata: Metadata = {
  title: "Sign In - Carific AI",
  description: "Sign in to your Carific AI account",
};

export default async function SignInPage() {
  // Redirect to dashboard if already authenticated
  await checkAuthAndRedirect({ disableCookieCache: true });

  return (
    <AuthCard
      title="Welcome back"
      description="Sign in to your account to continue"
      footer={{
        text: "Don't have an account?",
        linkText: "Sign up",
        linkHref: "/signup",
      }}
    >
      <SignInForm />
    </AuthCard>
  );
}
Enter fullscreen mode Exit fullscreen mode

Client Component (Form)

// components/auth/signin-form.tsx
"use client";

export function SignInForm() {
  const router = useRouter();
  const form = useAppForm({
    // ... form config
  });

  return <form>{/* Just the form, no layout */}</form>;
}
Enter fullscreen mode Exit fullscreen mode

Why This Split Matters

I initially had everything in client components. It worked, but:

  1. No SEO metadata - export const metadata only works in server components
  2. Flash of unauthenticated content - Auth checks happened client-side, so users saw the login form before being redirected
  3. Larger JS bundle - The entire page was client-rendered

The new architecture:

  • Metadata exports work (SEO-friendly)
  • Auth checks happen server-side (no flash)
  • Static layout stays on the server (smaller bundle)
  • Interactive logic is isolated to the smallest possible client component

Server-Side Auth Check

// lib/auth-check.ts
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { ROUTES } from "@/lib/constants";

export async function checkAuth(disableCookieCache = false) {
  return auth.api.getSession({
    headers: await headers(),
    query: { disableCookieCache },
  });
}

export async function checkAuthAndRedirect({
  redirectUrl = ROUTES.DASHBOARD,
  disableCookieCache = false,
} = {}) {
  const session = await checkAuth(disableCookieCache);
  if (session) {
    redirect(redirectUrl);
  }
}
Enter fullscreen mode Exit fullscreen mode

Chapter 4: The Little Things That Save Hours

Centralized Route Constants

After the third time I typo'd /dashbaord instead of /dashboard, I created this:

// lib/constants.ts
export const ROUTES = {
  DASHBOARD: "/dashboard",
  SIGN_IN: "/signin",
  SIGN_UP: "/signup",
  WELCOME: "/welcome",
} as const;
Enter fullscreen mode Exit fullscreen mode

Now TypeScript yells at me before I ship broken links. ROUTES.DASHBOARD everywhere, string typos nowhere.

Bonus: When I eventually rename /dashboard to /app, it's a one-line change.


The Final Architecture

app/
├── (auth)/
│   ├── layout.tsx        # Logo + centered layout
│   ├── loading.tsx       # Skeleton loader
│   ├── signin/page.tsx   # Server component
│   ├── signup/page.tsx   # Server component
│   └── welcome/page.tsx  # Post-signup "check your email"
├── dashboard/page.tsx
└── layout.tsx            # Root layout with Toaster

components/
├── auth/
│   ├── auth-card.tsx     # Reusable card wrapper
│   ├── signin-form.tsx   # Client form
│   └── signup-form.tsx   # Client form
├── form/
│   ├── form-base.tsx     # Field wrapper
│   ├── form-fields.tsx   # TextField, CheckboxField
│   └── form-components.tsx
└── ui/                   # shadcn/ui

hooks/
├── form.tsx              # useAppForm
└── form-context.tsx

lib/
├── auth-check.ts         # Server auth utilities
├── constants.ts          # Route constants
├── transactional-email.tsx
└── validations/auth.ts   # Zod schemas
Enter fullscreen mode Exit fullscreen mode

TL;DR - What I Learned

If you scrolled straight here (no judgment), here's the cheat sheet:

Problem Solution
React Email won't render in Next.js Use render() to convert to HTML string first
Outlook breaks your email styles Inline styles for critical properties, accept graceful degradation
TanStack Form boilerplate Use createFormHook composition pattern
useFormContext vs useFieldContext Field components use useFieldContext, form components use useFormContext
Flash of unauthenticated content Server-side auth checks in page components
Route string typos Centralized ROUTES constant object

Why Open Source?

This is my first open-source project, and I'm learning that building in public is terrifying and liberating at the same time.

Terrifying because my code is out there. Every questionable variable name, every "I'll refactor this later" comment visible to anyone who clicks the repo.

Liberating because I'm no longer building alone. CodeRabbit reviews my PRs. Future contributors might catch bugs I missed. And maybe just maybe someone learning auth will find this codebase useful.

Carific.ai is MIT licensed. That means:

  • ✅ Use it in your projects
  • ✅ Fork it and make it your own
  • ✅ Learn from the code (and my mistakes)
  • ✅ Contribute back if you want

The repo: github.com/ImAbdullahJan/carific.ai

If you find this useful, consider starring the repo - it helps others discover the project!


What's Next for Carific.ai

This auth system is just the foundation. Here's the roadmap I'm building toward:

  • 🔐 OAuth providers - Google and GitHub sign-in
  • 🔑 Password reset flow - With rate limiting and secure tokens
  • 👤 Session management - See and revoke active sessions
  • 🤖 The actual AI features - Career coaching, resume analysis, interview prep

I'll be documenting each feature as I build it. Follow along if you want to see how an open-source project evolves from zero to (hopefully) something useful.


Your Turn

This is my first post, and I'd genuinely love feedback:

  • On the code: See something that could be better? Open an issue or PR.
  • On the post: Too long? Too short? Missing something? Tell me.
  • On auth in general: What's your stack? Any war stories?

Building in public only works if there's a public to build with. Let's learn together.


If this post helped you or if you just want to encourage a first-time poster or drop a ❤️. It means more than you know.


Let's connect:

First post of many. See you in the next one.

Top comments (2)

Collapse
 
shariq_dd8f2e45b2d21e8505 profile image
Shariq

All the best, would love to see your journey unfold

Collapse
 
abdullahjan profile image
Abdullah Jan

Thank you for your appreciation.