Skip to content

better-auth

Drop-in authentication for Juice. better-auth handles sign-in, sign-up, sessions, and OAuth. Mount it on one route, check the session in onBeforeRequest, and share the user via typed context.

Install

bun add better-auth

Auth Configuration

Create an auth config file. This defines your auth providers, database, and session settings.

// lib/auth.ts
import { betterAuth } from 'better-auth';
import { Database } from 'bun:sqlite';

const db = new Database('auth.db');

export const auth = betterAuth({
  database: {
    db,
    type: 'sqlite',
  },
  emailAndPassword: {
    enabled: true,
  },
  // Optional: add OAuth providers
  // socialProviders: {
  //   github: {
  //     clientId: process.env.GITHUB_CLIENT_ID!,
  //     clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  //   },
  //   google: {
  //     clientId: process.env.GOOGLE_CLIENT_ID!,
  //     clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  //   },
  // },
});

Mount in server.ts

Route all /api/auth/* requests to better-auth. Then check the session in onBeforeRequest and share the user via context so every route can access it.

// server.ts
import { createRouter } from '@cmj/juice/runtime';
import { createContextKey, setContext } from '@cmj/juice/runtime';
import { auth } from './lib/auth.js';

// Typed context keys for user and session
interface User {
  id: string;
  email: string;
  name: string;
}

interface Session {
  id: string;
  userId: string;
  expiresAt: Date;
}

export const userKey = createContextKey<User | null>('user');
export const sessionKey = createContextKey<Session | null>('session');

const router = createRouter({
  onBeforeRequest: async (req) => {
    // Check session on every request
    const session = await auth.api.getSession({
      headers: req.headers,
    });

    setContext(req, userKey, session?.user ?? null);
    setContext(req, sessionKey, session?.session ?? null);
  },

  fetch: {
    // Mount better-auth on /api/auth/*
    '/api/auth/*': (req) => auth.handler(req),
  },

  // ... routes
});

export default {
  fetch: router.fetch,
};

Auth Middleware for Protected Routes

Create a middleware that redirects unauthenticated users to the login page. Place it in the directory containing your protected routes.

// app/routes/dashboard/middleware.ts
import { getContext, redirect } from '@cmj/juice/runtime';
import { userKey } from '../../../server.js';

export default async function authGuard(
  req: Request,
  next: () => Promise<Response>,
) {
  const user = getContext(req, userKey);

  if (!user) {
    redirect('/login', 303);
  }

  return next();
}

Login Page

A standard HTML form that POSTs credentials to better-auth's sign-in endpoint. Works without JavaScript.

// app/routes/login.tsx
import React from 'react';
import { getContext, redirect } from '@cmj/juice/runtime';
import { userKey } from '../../server.js';

async function signIn(formData: FormData) {
  'use server';
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  const res = await fetch('http://localhost:3000/api/auth/sign-in/email', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) {
    return { error: 'Invalid email or password' };
  }

  // Forward the Set-Cookie header from better-auth
  const setCookie = res.headers.get('set-cookie');
  if (setCookie) {
    redirect('/', 303, { headers: { 'Set-Cookie': setCookie } });
  }

  redirect('/', 303);
}

export default function Login({ request }: { request: Request }) {
  const user = getContext(request, userKey);

  // Already logged in — redirect to home
  if (user) {
    redirect('/', 303);
  }

  return (
    <div>
      <h2>Sign In</h2>
      <form action={signIn} method="POST">
        <label>
          Email
          <input type="email" name="email" required />
        </label>
        <label>
          Password
          <input type="password" name="password" required />
        </label>
        <button type="submit">Sign In</button>
      </form>
      <p>
        No account? <a href="/register">Register</a>
      </p>
    </div>
  );
}

export const response = {
  head: { title: 'Sign In' },
};

Register Page

// app/routes/register.tsx
import React from 'react';
import { redirect } from '@cmj/juice/runtime';

async function signUp(formData: FormData) {
  'use server';
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  const name = formData.get('name') as string;

  const res = await fetch('http://localhost:3000/api/auth/sign-up/email', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password, name }),
  });

  if (!res.ok) {
    const data = await res.json();
    return { error: data.message ?? 'Registration failed' };
  }

  const setCookie = res.headers.get('set-cookie');
  if (setCookie) {
    redirect('/', 303, { headers: { 'Set-Cookie': setCookie } });
  }

  redirect('/login', 303);
}

export default function Register() {
  return (
    <div>
      <h2>Create Account</h2>
      <form action={signUp} method="POST">
        <label>
          Name
          <input type="text" name="name" required />
        </label>
        <label>
          Email
          <input type="email" name="email" required />
        </label>
        <label>
          Password
          <input type="password" name="password" required minLength={8} />
        </label>
        <button type="submit">Register</button>
      </form>
      <p>
        Already have an account? <a href="/login">Sign in</a>
      </p>
    </div>
  );
}

export const response = {
  head: { title: 'Register' },
};

Reading the Session in Components

Any server component can read the current user from context. The session was already resolved in onBeforeRequest — no extra database calls.

// app/routes/dashboard/home.tsx
import React from 'react';
import { getContext } from '@cmj/juice/runtime';
import { userKey } from '../../../server.js';

export default function Dashboard({ request }: { request: Request }) {
  const user = getContext(request, userKey);

  return (
    <div>
      <h2>Dashboard</h2>
      <p>Welcome back, {user!.name}.</p>
      <p>Email: {user!.email}</p>
    </div>
  );
}

export const response = {
  head: { title: 'Dashboard' },
};

Sign Out

Add a sign-out link that POSTs to better-auth's sign-out endpoint. A form with a submit button works without JavaScript.

// In any component with access to the user
<form action="/api/auth/sign-out" method="POST">
  <button type="submit">Sign Out</button>
</form>

Or as a server action that clears the session and redirects:

async function signOut() {
  'use server';

  const res = await fetch('http://localhost:3000/api/auth/sign-out', {
    method: 'POST',
    headers: this.request.headers,
  });

  const setCookie = res.headers.get('set-cookie');
  if (setCookie) {
    redirect('/login', 303, { headers: { 'Set-Cookie': setCookie } });
  }

  redirect('/login', 303);
}

The pattern. better-auth handles all /api/auth/* routes: sign-in, sign-up, sign-out, OAuth callbacks, session management. Juice handles everything else. The session is checked once in onBeforeRequest and shared via typed context — no repeated database lookups, no prop drilling, full type safety from server.ts through to your components.

Other Runtimes

The examples above use Bun. No changes needed. better-auth uses the fetch API internally — it works on any WinterCG runtime.

Use D1 as the database provider. Pass the D1 binding from the Workers env to auth.

import { betterAuth } from 'better-auth';

export const auth = betterAuth({
  database: {
    provider: 'd1',
    // D1 binding passed from Workers env
  },
});

// In server entry, pass env.DB to auth

Works with Deno. Use SQLite or Postgres as the database provider. better-auth's fetch-based internals are fully Deno-compatible.

Works out of the box. Use any supported database provider (SQLite, Postgres, MySQL, MongoDB).