1---
2title: "Part 3: Build sign-in"
3description: Add a same-origin browser client with sign-up, sign-in, session lookup, and sign-out.
4sidebar:
5 order: 3
6---
7
8Continue from [Part 2](/better-auth/tutorial/part-2). Serve a small browser client from the same Worker so cookies work without cross-origin configuration.
9
10## Create the client
11
12```typescript
13// src/client.ts
14import { createAuthClient } from "better-auth/client";
15
16export const authClient = createAuthClient();
17```
18
19The client uses the current origin and the default `/api/auth` path. It contains no database credentials or signing secret.
20
21## Add a form
22
23```html
24<!-- public/index.html -->
25<!doctype html>
26<html lang="en">
27 <head>
28 <meta charset="utf-8" />
29 <meta name="viewport" content="width=device-width, initial-scale=1" />
30 <title>Auth tutorial</title>
31 </head>
32 <body>
33 <h1>Auth tutorial</h1>
34 <form id="auth-form">
35 <label>Name <input name="name" autocomplete="name" /></label>
36 <label>Email <input name="email" type="email" autocomplete="email" required /></label>
37 <label>Password <input name="password" type="password" autocomplete="current-password" minlength="8" required /></label>
38 <button type="submit">Sign in</button>
39 <button id="sign-up" type="button">Create account</button>
40 </form>
41 <button id="session" type="button">Read session</button>
42 <button id="sign-out" type="button">Sign out</button>
43 <p id="status" role="status"></p>
44 <script type="module" src="/ui.js"></script>
45 </body>
46</html>
47```
48
49Create the `public` directory before saving this file. Keep this page public so signed-out users can reach it.
50
51## Read the form
52
53```typescript
54// src/ui.ts
55import { authClient } from "./client.ts";
56
57const form = document.querySelector<HTMLFormElement>("#auth-form")!;
58const status = document.querySelector<HTMLParagraphElement>("#status")!;
59const show = (message: string) => { status.textContent = message; };
60
61const credentials = () => {
62 const fields = new FormData(form);
63 return {
64 name: String(fields.get("name") ?? ""),
65 email: String(fields.get("email") ?? ""),
66 password: String(fields.get("password") ?? ""),
67 };
68};
69```
70
71Use text content for status messages rather than inserting server responses as HTML.
72
73## Create an account
74
75```typescript
76// Append to src/ui.ts
77const signUp = async () => {
78 if (!form.reportValidity()) return;
79 const input = credentials();
80 if (!input.name.trim()) return show("Enter a name to create an account.");
81 const { data, error } = await authClient.signUp.email(input);
82 show(error ? error.message ?? "Sign-up failed" : `Created ${data.user.email}`);
83};
84
85document.querySelector("#sign-up")!.addEventListener("click", () => {
86 void signUp().catch(() => show("Unable to reach the server."));
87});
88```
89
90The server creates the user and sets the session cookie. Later, the [email/password guide](/better-auth/sign-in-providers/email-password) adds email verification and password reset.
91
92## Sign in
93
94```typescript
95// Append to src/ui.ts
96const signIn = async () => {
97 const { email, password } = credentials();
98 const { data, error } = await authClient.signIn.email({ email, password });
99 show(error ? error.message ?? "Sign-in failed" : `Signed in as ${data.user.email}`);
100};
101
102form.addEventListener("submit", (event) => {
103 event.preventDefault();
104 void signIn().catch(() => show("Unable to reach the server."));
105});
106```
107
108A failed sign-in stays on the form and displays the error.
109
110## Read the session
111
112```typescript
113// Append to src/ui.ts
114const readSession = async () => {
115 const { data, error } = await authClient.getSession();
116 show(error ? error.message ?? "Session lookup failed"
117 : data ? `Signed in as ${data.user.email}` : "Signed out");
118};
119
120document.querySelector("#session")!.addEventListener("click", () => {
121 void readSession().catch(() => show("Unable to reach the server."));
122});
123```
124
125The browser sends its session cookie automatically. The server remains authoritative about whether the user is signed in.
126
127## Sign out
128
129```typescript
130// Append to src/ui.ts
131const signOut = async () => {
132 const { error } = await authClient.signOut();
133 show(error ? error.message ?? "Sign-out failed" : "Signed out");
134};
135
136document.querySelector("#sign-out")!.addEventListener("click", () => {
137 void signOut().catch(() => show("Unable to reach the server."));
138});
139```
140
141Sign-out revokes the session and clears its cookie.
142
143## Build the browser code
144
145```sh
146bun build ./src/ui.ts --target browser --outdir ./public
147```
148
149Run this again whenever the browser source changes. Only browser code belongs in this bundle; never import the server's Auth service here.
150
151## Serve the assets
152
153```diff lang="typescript"
154 // src/worker.ts
155 {
156 main: import.meta.url,
157+ assets: "./public",
158 compatibility: { flags: ["nodejs_compat"] },
159```
160
161Cloudflare serves matching static files and forwards API requests to the Worker. The form and authentication endpoints share an origin.
162
163## Try the flow
164
165Open the URL printed by Alchemy, create an account, and read the session. Sign out, try an incorrect password, then sign in with the correct password.
166
167Continue to [Part 4: Protect API endpoints](/better-auth/tutorial/part-4).