| 1 | --- |
| 2 | title: "Part 3: Build sign-in" |
| 3 | description: Add a same-origin browser client with sign-up, sign-in, session lookup, and sign-out. |
| 4 | sidebar: |
| 5 | order: 3 |
| 6 | --- |
| 7 | |
| 8 | Continue 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 |
| 14 | import { createAuthClient } from "better-auth/client"; |
| 15 | |
| 16 | export const authClient = createAuthClient(); |
| 17 | ``` |
| 18 | |
| 19 | The 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 | |
| 49 | Create 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 |
| 55 | import { authClient } from "./client.ts"; |
| 56 | |
| 57 | const form = document.querySelector<HTMLFormElement>("#auth-form")!; |
| 58 | const status = document.querySelector<HTMLParagraphElement>("#status")!; |
| 59 | const show = (message: string) => { status.textContent = message; }; |
| 60 | |
| 61 | const 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 | |
| 71 | Use 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 |
| 77 | const 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 | |
| 85 | document.querySelector("#sign-up")!.addEventListener("click", () => { |
| 86 | void signUp().catch(() => show("Unable to reach the server.")); |
| 87 | }); |
| 88 | ``` |
| 89 | |
| 90 | The 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 |
| 96 | const 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 | |
| 102 | form.addEventListener("submit", (event) => { |
| 103 | event.preventDefault(); |
| 104 | void signIn().catch(() => show("Unable to reach the server.")); |
| 105 | }); |
| 106 | ``` |
| 107 | |
| 108 | A 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 |
| 114 | const 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 | |
| 120 | document.querySelector("#session")!.addEventListener("click", () => { |
| 121 | void readSession().catch(() => show("Unable to reach the server.")); |
| 122 | }); |
| 123 | ``` |
| 124 | |
| 125 | The 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 |
| 131 | const signOut = async () => { |
| 132 | const { error } = await authClient.signOut(); |
| 133 | show(error ? error.message ?? "Sign-out failed" : "Signed out"); |
| 134 | }; |
| 135 | |
| 136 | document.querySelector("#sign-out")!.addEventListener("click", () => { |
| 137 | void signOut().catch(() => show("Unable to reach the server.")); |
| 138 | }); |
| 139 | ``` |
| 140 | |
| 141 | Sign-out revokes the session and clears its cookie. |
| 142 | |
| 143 | ## Build the browser code |
| 144 | |
| 145 | ```sh |
| 146 | bun build ./src/ui.ts --target browser --outdir ./public |
| 147 | ``` |
| 148 | |
| 149 | Run 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 | |
| 161 | Cloudflare 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 | |
| 165 | Open 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 | |
| 167 | Continue to [Part 4: Protect API endpoints](/better-auth/tutorial/part-4). |