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-authAuth 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 authWorks 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).