├── .prettierrc ├── public └── favicon.ico ├── .gitignore ├── database ├── schema.ts └── auth-schema.ts ├── app ├── app.css ├── routes.ts ├── routes │ ├── auth.ts │ └── home.tsx ├── auth │ ├── auth-client.ts │ └── auth.server.ts ├── entry.server.tsx └── root.tsx ├── drizzle ├── meta │ ├── _journal.json │ └── 0000_snapshot.json └── 0000_boring_jocasta.sql ├── react-router.config.ts ├── tsconfig.json ├── worker-configuration.d.ts ├── tsconfig.node.json ├── .dev-example.vars ├── tailwind.config.ts ├── drizzle.config.ts ├── auth.ts ├── tsconfig.cloudflare.json ├── LICENSE ├── load-context.ts ├── wrangler.toml ├── workers └── app.ts ├── README.md ├── vite.config.ts └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthewlynch/better-auth-react-router-cloudflare-d1/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | 4 | # React Router 5 | /.react-router/ 6 | /build/ 7 | 8 | # Cloudflare 9 | .dev.vars 10 | .mf 11 | .wrangler 12 | -------------------------------------------------------------------------------- /database/schema.ts: -------------------------------------------------------------------------------- 1 | import { user, session, account, verification } from "./auth-schema"; 2 | 3 | export const schema = { user, session, account, verification }; 4 | 5 | export { user, session, account, verification }; 6 | -------------------------------------------------------------------------------- /app/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | @apply bg-white dark:bg-gray-950; 8 | 9 | @media (prefers-color-scheme: dark) { 10 | color-scheme: dark; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import type { RouteConfig } from "@react-router/dev/routes"; 2 | import { index, route } from "@react-router/dev/routes"; 3 | 4 | export default [ 5 | index("routes/home.tsx"), 6 | route("api/auth/*", "routes/auth.ts"), 7 | ] satisfies RouteConfig; 8 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1735916869708, 9 | "tag": "0000_boring_jocasta", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | // Config options... 5 | // Server-side render by default, to enable SPA mode set this to `false` 6 | ssr: true, 7 | serverBuildFile: "index.js", 8 | async prerender() { 9 | return []; 10 | }, 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.node.json" }, 5 | { "path": "./tsconfig.cloudflare.json" } 6 | ], 7 | "compilerOptions": { 8 | "allowJs": true, 9 | "checkJs": true, 10 | "verbatimModuleSyntax": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "noEmit": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types` 2 | 3 | interface Env { 4 | CLOUDFLARE_ACCOUNT_ID: string; 5 | CLOUDFLARE_DATABASE_ID: string; 6 | CLOUDFLARE_TOKEN: string; 7 | BETTER_AUTH_SECRET: string; 8 | BETTER_AUTH_URL: string; 9 | OAUTH_GITHUB_CLIENT_ID: string; 10 | OAUTH_GITHUB_CLIENT_SECRET: string; 11 | DB: D1Database; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["tailwind.config.ts", "drizzle.config.ts", "react-router.config.ts"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "strict": true, 7 | "types": ["node"], 8 | "lib": ["ES2022"], 9 | "target": "ES2022", 10 | "module": "ES2022", 11 | "moduleResolution": "bundler" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { getAuth } from "~/auth/auth.server"; 2 | 3 | import type { Route } from "./+types/auth"; 4 | 5 | export async function loader({ context, request }: Route.LoaderArgs) { 6 | const auth = getAuth(context); 7 | return auth.handler(request); 8 | } 9 | 10 | export async function action({ context, request }: Route.ActionArgs) { 11 | const auth = getAuth(context); 12 | return auth.handler(request); 13 | } 14 | -------------------------------------------------------------------------------- /app/auth/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | 3 | let authClient: ReturnType; 4 | 5 | export function getAuthClient({ 6 | // the base url of your auth server 7 | baseURL = "http://localhost:5173", 8 | }: { 9 | baseURL?: string; 10 | }) { 11 | if (!authClient) { 12 | authClient = createAuthClient({ 13 | baseURL, 14 | }); 15 | } 16 | 17 | return authClient; 18 | } 19 | -------------------------------------------------------------------------------- /.dev-example.vars: -------------------------------------------------------------------------------- 1 | # These values are required to make drizzle studio work when viewing remote database (pnpm db:studio:production) 2 | CLOUDFLARE_ACCOUNT_ID="" 3 | CLOUDFLARE_DATABASE_ID="" 4 | # Enter values that are supposed to be kept secert in this file below this line 5 | # Don't check them in or add them to wrangler.toml 6 | CLOUDFLARE_TOKEN="" 7 | BETTER_AUTH_SECRET="" 8 | BETTER_AUTH_URL="http://localhost:5173" 9 | OAUTH_GITHUB_CLIENT_ID="" 10 | OAUTH_GITHUB_CLIENT_SECRET="" 11 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: [ 9 | '"Inter"', 10 | "ui-sans-serif", 11 | "system-ui", 12 | "sans-serif", 13 | '"Apple Color Emoji"', 14 | '"Segoe UI Emoji"', 15 | '"Segoe UI Symbol"', 16 | '"Noto Color Emoji"', 17 | ], 18 | }, 19 | }, 20 | }, 21 | plugins: [], 22 | } satisfies Config; 23 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | const LOCAL_DB_PATH = process.env.LOCAL_DB_PATH; 4 | 5 | export default LOCAL_DB_PATH 6 | ? ({ 7 | schema: "./database/schema.ts", 8 | dialect: "sqlite", 9 | dbCredentials: { 10 | url: LOCAL_DB_PATH, 11 | }, 12 | } satisfies Config) 13 | : ({ 14 | schema: "./database/schema.ts", 15 | out: "./drizzle", 16 | dialect: "sqlite", 17 | driver: "d1-http", 18 | dbCredentials: { 19 | accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, 20 | databaseId: process.env.CLOUDFLARE_DATABASE_ID!, 21 | token: process.env.CLOUDFLARE_TOKEN!, 22 | }, 23 | } satisfies Config); 24 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | // This file is *ONLY* used by the CLI! 2 | // Run `pnpm authgen` to generate the drizzle auth schema found 3 | // in ./database/auth-schema.ts when ever you make changes to your 4 | // better auth config 5 | 6 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 7 | import { drizzle } from "drizzle-orm/better-sqlite3"; 8 | 9 | import { createBetterAuth } from "./app/auth/auth.server"; 10 | import { schema } from "./database/schema"; 11 | 12 | const db = drizzle({ connection: { source: process.env.LOCAL_DB_PATH } }); 13 | const database = drizzleAdapter(db, { 14 | schema, 15 | provider: "sqlite", 16 | usePlural: false, 17 | }); 18 | 19 | export const auth = createBetterAuth(database, { 20 | BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, 21 | OAUTH_GITHUB_CLIENT_ID: process.env.OAUTH_GITHUB_CLIENT_ID, 22 | OAUTH_GITHUB_CLIENT_SECRET: process.env.OAUTH_OAUTH_GITHUB_CLIENT_SECRET, 23 | } as { 24 | BETTER_AUTH_SECRET: string; 25 | OAUTH_GITHUB_CLIENT_ID: string; 26 | OAUTH_GITHUB_CLIENT_SECRET: string; 27 | }); 28 | -------------------------------------------------------------------------------- /tsconfig.cloudflare.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "worker-configuration.d.ts", 5 | "load-context.ts", 6 | ".react-router/types/**/*", 7 | "app/**/*", 8 | "app/**/.server/**/*", 9 | "app/**/.client/**/*", 10 | "database/**/*", 11 | "workers/**/*", 12 | "auth.ts", 13 | "vite.config.ts", 14 | ], 15 | "compilerOptions": { 16 | "allowJs": true, 17 | "checkJs": true, 18 | "skipLibCheck": true, 19 | "composite": true, 20 | "strict": true, 21 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 22 | "types": ["@cloudflare/workers-types", "node", "vite/client"], 23 | "target": "ES2022", 24 | "module": "ES2022", 25 | "moduleResolution": "bundler", 26 | "jsx": "react-jsx", 27 | "baseUrl": ".", 28 | "rootDirs": [".", "./.react-router/types"], 29 | "paths": { 30 | "~/database/*": ["./database/*"], 31 | "~/*": ["./app/*"] 32 | }, 33 | "esModuleInterop": true, 34 | "resolveJsonModule": true, 35 | "noEmit": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matthew Lynch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /load-context.ts: -------------------------------------------------------------------------------- 1 | import type { PlatformProxy } from "wrangler"; 2 | import { drizzle } from "drizzle-orm/d1"; 3 | 4 | import { schema } from "./database/schema"; 5 | 6 | // `Env` is defined in worker-configuration.d.ts 7 | 8 | type GetLoadContextArgs = { 9 | request: Request; 10 | context: { 11 | cloudflare: Omit< 12 | PlatformProxy, 13 | "dispose" | "caches" 14 | > & { 15 | caches: 16 | | PlatformProxy["caches"] 17 | | CacheStorage; 18 | }; 19 | }; 20 | }; 21 | 22 | declare module "react-router" { 23 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 24 | interface AppLoadContext extends ReturnType { 25 | // This will merge the result of `getLoadContext` into the `AppLoadContext` 26 | } 27 | } 28 | 29 | // Shared implementation compatible with Vite, Wrangler, and Cloudflare Workers 30 | export function getLoadContext({ context }: GetLoadContextArgs) { 31 | const db = drizzle(context.cloudflare.env.DB, { schema }); 32 | 33 | return { 34 | ...context, 35 | db, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | 3 | # Visit https://developers.cloudflare.com/workers/wrangler/configuration/ to see full list of options for this file 4 | 5 | # This will be the name of your worker in the Cloudflare Dashboard 6 | name = "better-auth-react-router-cloudflare-d1" 7 | # Compatibility date docs: https://developers.cloudflare.com/workers/configuration/compatibility-dates/ 8 | compatibility_date = "2024-11-18" 9 | # See https://developers.cloudflare.com/workers/configuration/compatibility-flags/ for full list of flags 10 | compatibility_flags = ["nodejs_compat"] 11 | # Entry point for the worker app 12 | main = "./build/server/worker.js" 13 | 14 | [build] 15 | command = "pnpm build" 16 | 17 | [assets] 18 | # https://developers.cloudflare.com/workers/static-assets/binding/ - these are the static assets served to clients 19 | directory = "./build/client" 20 | 21 | # These vars apply to both development & production 22 | [vars] 23 | # Values defined in .dev.vars will overwrite values here 24 | BETTER_AUTH_URL="https://better-auth-react-router-cloudflare-d1.mattlynch.workers.dev" 25 | CLOUDFLARE_ACCOUNT_ID="" 26 | CLOUDFLARE_DATABASE_ID="" 27 | 28 | [[d1_databases]] 29 | binding = "DB" 30 | database_name = "better-auth-example" 31 | database_id = "07ec7bea-f5c0-4e14-a646-d83197cb9c43" 32 | migrations_dir = "drizzle" 33 | -------------------------------------------------------------------------------- /workers/app.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "react-router"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 4 | // @ts-ignore This file won’t exist if it hasn’t yet been built 5 | // import * as build from "./build/server"; 6 | import { getLoadContext } from "../load-context"; 7 | 8 | // const requestHandler = createRequestHandler( 9 | // build as unknown as ServerBuild, 10 | // import.meta.env.MODE, 11 | // ); 12 | 13 | const requestHandler = createRequestHandler( 14 | // @ts-expect-error - virtual module provided by React Router at build time 15 | () => import("virtual:react-router/server-build"), 16 | import.meta.env.MODE, 17 | ); 18 | 19 | export default { 20 | async fetch(request, env, ctx) { 21 | const loadContext = getLoadContext({ 22 | request, 23 | context: { 24 | cloudflare: { 25 | // This object matches the return value from Wrangler's 26 | // `getPlatformProxy` used during development via Remix's 27 | // `cloudflareDevProxyVitePlugin`: 28 | // https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy 29 | // @ts-ignore 30 | cf: request.cf, 31 | ctx: { 32 | waitUntil: ctx.waitUntil.bind(ctx), 33 | passThroughOnException: ctx.passThroughOnException.bind(ctx), 34 | }, 35 | caches, 36 | env, 37 | }, 38 | }, 39 | }); 40 | 41 | return await requestHandler(request, loadContext); 42 | }, 43 | } satisfies ExportedHandler; 44 | -------------------------------------------------------------------------------- /drizzle/0000_boring_jocasta.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `account` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `account_id` text NOT NULL, 4 | `provider_id` text NOT NULL, 5 | `user_id` text NOT NULL, 6 | `access_token` text, 7 | `refresh_token` text, 8 | `id_token` text, 9 | `access_token_expires_at` integer, 10 | `refresh_token_expires_at` integer, 11 | `scope` text, 12 | `password` text, 13 | `created_at` integer NOT NULL, 14 | `updated_at` integer NOT NULL, 15 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 16 | ); 17 | --> statement-breakpoint 18 | CREATE TABLE `session` ( 19 | `id` text PRIMARY KEY NOT NULL, 20 | `expires_at` integer NOT NULL, 21 | `token` text NOT NULL, 22 | `created_at` integer NOT NULL, 23 | `updated_at` integer NOT NULL, 24 | `ip_address` text, 25 | `user_agent` text, 26 | `user_id` text NOT NULL, 27 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 28 | ); 29 | --> statement-breakpoint 30 | CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint 31 | CREATE TABLE `user` ( 32 | `id` text PRIMARY KEY NOT NULL, 33 | `name` text NOT NULL, 34 | `email` text NOT NULL, 35 | `email_verified` integer NOT NULL, 36 | `image` text, 37 | `created_at` integer NOT NULL, 38 | `updated_at` integer NOT NULL 39 | ); 40 | --> statement-breakpoint 41 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint 42 | CREATE TABLE `verification` ( 43 | `id` text PRIMARY KEY NOT NULL, 44 | `identifier` text NOT NULL, 45 | `value` text NOT NULL, 46 | `expires_at` integer NOT NULL, 47 | `created_at` integer, 48 | `updated_at` integer 49 | ); 50 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { AppLoadContext, EntryContext } from "react-router"; 2 | import { ServerRouter } from "react-router"; 3 | import { isbot } from "isbot"; 4 | import { renderToReadableStream } from "react-dom/server"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | routerContext: EntryContext, 11 | _loadContext: AppLoadContext, 12 | ) { 13 | let shellRendered = false; 14 | const userAgent = request.headers.get("user-agent"); 15 | 16 | const body = await renderToReadableStream( 17 | , 18 | { 19 | onError(error: unknown) { 20 | responseStatusCode = 500; 21 | // Log streaming rendering errors from inside the shell. Don't log 22 | // errors encountered during initial shell rendering since they'll 23 | // reject and get logged in handleDocumentRequest. 24 | if (shellRendered) { 25 | console.error(error); 26 | } 27 | }, 28 | }, 29 | ); 30 | shellRendered = true; 31 | 32 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 33 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 34 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 35 | await body.allReady; 36 | } else { 37 | responseHeaders.set("Transfer-Encoding", "chunked"); 38 | } 39 | 40 | responseHeaders.set("Content-Type", "text/html"); 41 | 42 | return new Response(body, { 43 | headers: responseHeaders, 44 | status: responseStatusCode, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /app/auth/auth.server.ts: -------------------------------------------------------------------------------- 1 | import type { BetterAuthOptions } from "better-auth"; 2 | import { betterAuth } from "better-auth"; 3 | import { Kysely, CamelCasePlugin } from "kysely"; 4 | import { D1Dialect } from "@noxharmonium/kysely-d1"; 5 | import type { AppLoadContext } from "react-router"; 6 | 7 | let authInstance: ReturnType; 8 | 9 | export function createBetterAuth( 10 | database: BetterAuthOptions["database"], 11 | env: { 12 | BETTER_AUTH_SECRET: string; 13 | OAUTH_GITHUB_CLIENT_ID: string; 14 | OAUTH_GITHUB_CLIENT_SECRET: string; 15 | }, 16 | ) { 17 | if (!authInstance) { 18 | authInstance = betterAuth({ 19 | database, 20 | emailAndPassword: { 21 | enabled: false, 22 | }, 23 | secret: env.BETTER_AUTH_SECRET, 24 | socialProviders: { 25 | github: { 26 | clientId: env.OAUTH_GITHUB_CLIENT_ID, 27 | clientSecret: env.OAUTH_GITHUB_CLIENT_SECRET, 28 | }, 29 | }, 30 | }); 31 | } 32 | 33 | return authInstance; 34 | } 35 | 36 | export function getAuth(ctx: AppLoadContext) { 37 | if (!authInstance) { 38 | authInstance = createBetterAuth( 39 | { 40 | // This project uses D1 so we have to use an instance of Kysely. 41 | // You could swap this out if you're using a different database. 42 | db: new Kysely({ 43 | dialect: new D1Dialect({ 44 | database: ctx.cloudflare.env.DB, 45 | }), 46 | plugins: [ 47 | // Drizzle schema uses snake_case so this plugin is required for 48 | // better-auth to talk to the database 49 | new CamelCasePlugin(), 50 | ], 51 | }), 52 | type: "sqlite", 53 | }, 54 | ctx.cloudflare.env, 55 | ); 56 | } 57 | 58 | return authInstance; 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better Auth + Drizzle + React Router on Cloudflare Workers with D1 2 | 3 | This repo was generated from the [`react-router-cloudflare-d1` template](https://github.com/matthewlynch/react-router-cloudflare-d1) 4 | 5 | You can start a react-router project from this repo by running: 6 | 7 | ``` 8 | npx create-react-router@latest --template matthewlynch/better-auth-react-router-cloudflare-d1 9 | ``` 10 | 11 | ## Getting Started 12 | 13 | 1. Run `cp .dev-example.vars .dev.vars` to create an .env file you can use to override variables defined in `wrangler.toml` or set secret values you don't want to check into source control 14 | 2. Update the `name` field in `wranlger.toml` 15 | 3. Install dependencies `pnpm install` 16 | 4. Create a database by running [`wrangler d1 create `](https://developers.cloudflare.com/d1/wrangler-commands/#d1-create) and update `wranlger.toml` with the UUID and name for the database 17 | 5. Add your GitHub OAuth Client ID/Secret & optionally your Cloudflare Account ID/Database UUID/Token to `.dev.vars` (you only need this when you want to view data via Drizzle Studio for your remote database) 18 | 6. Run `pnpm typegen` any time you make changes to `wranlger.toml` to ensure types from bindings and module rules are up to date for type safety 19 | 7. Run `pnpm db:warm` to ensure wrangler has created a local database 20 | 8. Run `pnpm db:migrate` to apply the migration files to your local database 21 | 9. Run `pnpm dev` to start the app 22 | 23 | ## Better Auth config 24 | 25 | 1. Make changes to the `better-auth` config in `./app/auth/auth.server.ts` 26 | 2. Run `pnpm auth:db:generate` to create the `better-auth` drizzle schema file `./database/auth-schema.ts` 27 | 3. Run `pnpm db:generate` to create migration files 28 | 4. Run `pnpm db:migrate` to apply the migration files to your local database 29 | 30 | --- 31 | 32 | Built with ❤️ by [Matt](https://mattlynch.dev) 33 | -------------------------------------------------------------------------------- /app/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/home"; 2 | import { getAuth } from "~/auth/auth.server"; 3 | import { getAuthClient } from "~/auth/auth-client"; 4 | 5 | export function meta({}: Route.MetaArgs) { 6 | return [ 7 | { title: "Better Auth / React Router App + Cloudflare Workers" }, 8 | { 9 | name: "description", 10 | content: "Welcome to React Router hosted on Cloudflare Workers!", 11 | }, 12 | ]; 13 | } 14 | 15 | export async function loader({ context, request }: Route.LoaderArgs) { 16 | const auth = getAuth(context); 17 | const session = await auth.api.getSession({ headers: request.headers }); 18 | 19 | return { 20 | baseURL: context.cloudflare.env.BETTER_AUTH_URL, 21 | user: session?.user, 22 | }; 23 | } 24 | 25 | export default function Home({ loaderData }: Route.ComponentProps) { 26 | const { signIn } = getAuthClient({ baseURL: loaderData.baseURL }); 27 | 28 | const signInGitHub = async () => { 29 | await signIn.social({ 30 | provider: "github", 31 | }); 32 | }; 33 | 34 | return ( 35 |
36 |

37 | Better Auth example (hosted on Cloudflare Workers) 38 |

39 | {loaderData.user ? ( 40 |
41 | {JSON.stringify(loaderData.user)} 42 |
43 | ) : ( 44 |
45 | 51 |
52 | )} 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare"; 2 | import { reactRouter } from "@react-router/dev/vite"; 3 | import autoprefixer from "autoprefixer"; 4 | import tailwindcss from "tailwindcss"; 5 | import { defineConfig } from "vite"; 6 | import tsconfigPaths from "vite-tsconfig-paths"; 7 | 8 | import { getLoadContext } from "./load-context"; 9 | 10 | export default defineConfig(({ isSsrBuild }) => ({ 11 | build: { 12 | cssMinify: process.env.NODE_ENV === "production", 13 | rollupOptions: isSsrBuild 14 | ? { 15 | input: { 16 | // `index.js` is the entrypoint for pre-rendering at build time 17 | "index.js": "virtual:react-router/server-build", 18 | // `worker.js` is the entrypoint for deployments on Cloudflare 19 | "worker.js": "./workers/app.ts", 20 | }, 21 | } 22 | : undefined, 23 | }, 24 | css: { 25 | postcss: { 26 | plugins: [tailwindcss, autoprefixer], 27 | }, 28 | }, 29 | ssr: { 30 | target: "webworker", 31 | noExternal: true, 32 | external: ["node:async_hooks"], 33 | resolve: { 34 | conditions: ["workerd", "browser"], 35 | }, 36 | optimizeDeps: { 37 | include: [ 38 | "react", 39 | "react/jsx-runtime", 40 | "react/jsx-dev-runtime", 41 | "react-dom", 42 | "react-dom/server", 43 | "react-router", 44 | ], 45 | }, 46 | }, 47 | plugins: [ 48 | cloudflareDevProxy({ getLoadContext }), 49 | reactRouter(), 50 | { 51 | // This plugin is required so both `index.js` / `worker.js can be 52 | // generated for the `build` config above 53 | name: "react-router-cloudflare-workers", 54 | config: () => ({ 55 | build: { 56 | rollupOptions: isSsrBuild 57 | ? { 58 | output: { 59 | entryFileNames: "[name]", 60 | }, 61 | } 62 | : undefined, 63 | }, 64 | }), 65 | }, 66 | tsconfigPaths(), 67 | ], 68 | })); 69 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "react-router"; 9 | 10 | import type { Route } from "./+types/root"; 11 | import stylesheet from "./app.css?url"; 12 | 13 | export const links: Route.LinksFunction = () => [ 14 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 15 | { 16 | rel: "preconnect", 17 | href: "https://fonts.gstatic.com", 18 | crossOrigin: "anonymous", 19 | }, 20 | { 21 | rel: "stylesheet", 22 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 23 | }, 24 | { rel: "stylesheet", href: stylesheet }, 25 | ]; 26 | 27 | export function Layout({ children }: { children: React.ReactNode }) { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {children} 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | export default function App() { 46 | return ; 47 | } 48 | 49 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 50 | let message = "Oops!"; 51 | let details = "An unexpected error occurred."; 52 | let stack: string | undefined; 53 | 54 | if (isRouteErrorResponse(error)) { 55 | message = error.status === 404 ? "404" : "Error"; 56 | details = 57 | error.status === 404 58 | ? "The requested page could not be found." 59 | : error.statusText || details; 60 | } else if (import.meta.env.DEV && error && error instanceof Error) { 61 | details = error.message; 62 | stack = error.stack; 63 | } 64 | 65 | return ( 66 |
67 |

{message}

68 |

{details}

69 | {stack && ( 70 |
71 |           {stack}
72 |         
73 | )} 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /database/auth-schema.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; 2 | 3 | export const user = sqliteTable("user", { 4 | id: text("id").primaryKey(), 5 | name: text("name").notNull(), 6 | email: text("email").notNull().unique(), 7 | emailVerified: integer("email_verified", { mode: "boolean" }).notNull(), 8 | image: text("image"), 9 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 10 | updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), 11 | }); 12 | 13 | export const session = sqliteTable("session", { 14 | id: text("id").primaryKey(), 15 | expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), 16 | token: text("token").notNull().unique(), 17 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 18 | updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), 19 | ipAddress: text("ip_address"), 20 | userAgent: text("user_agent"), 21 | userId: text("user_id") 22 | .notNull() 23 | .references(() => user.id), 24 | }); 25 | 26 | export const account = sqliteTable("account", { 27 | id: text("id").primaryKey(), 28 | accountId: text("account_id").notNull(), 29 | providerId: text("provider_id").notNull(), 30 | userId: text("user_id") 31 | .notNull() 32 | .references(() => user.id), 33 | accessToken: text("access_token"), 34 | refreshToken: text("refresh_token"), 35 | idToken: text("id_token"), 36 | accessTokenExpiresAt: integer("access_token_expires_at", { 37 | mode: "timestamp", 38 | }), 39 | refreshTokenExpiresAt: integer("refresh_token_expires_at", { 40 | mode: "timestamp", 41 | }), 42 | scope: text("scope"), 43 | password: text("password"), 44 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 45 | updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), 46 | }); 47 | 48 | export const verification = sqliteTable("verification", { 49 | id: text("id").primaryKey(), 50 | identifier: text("identifier").notNull(), 51 | value: text("value").notNull(), 52 | expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), 53 | createdAt: integer("created_at", { mode: "timestamp" }), 54 | updatedAt: integer("updated_at", { mode: "timestamp" }), 55 | }); 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-auth-react-router-cloudflare-d1", 3 | "private": true, 4 | "description": "Better Auth + React Router Cloudflare D1 template", 5 | "repository": { 6 | "url": "github:matthewlynch/better-auth-react-router-cloudflare-d1" 7 | }, 8 | "license": "MIT", 9 | "sideEffects": false, 10 | "type": "module", 11 | "scripts": { 12 | "build": "NODE_ENV=production react-router build", 13 | "db:generate": "dotenvx run -f .dev.vars -- drizzle-kit generate", 14 | "db:migrate": "wrangler d1 migrations apply --local DB", 15 | "db:migrate:production": "dotenvx run -f .dev.vars -- drizzle-kit migrate", 16 | "db:studio": "LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio", 17 | "db:studio:production": "dotenvx run -f .dev.vars -- drizzle-kit studio", 18 | "db:warm": "npx wrangler d1 execute better-auth-example --command \"SELECT * FROM sqlite_master where type='table'\"", 19 | "deploy:production": "wrangler deploy", 20 | "dev": "react-router dev", 21 | "deploy:version": "wrangler versions upload", 22 | "start": "wrangler dev", 23 | "typecheck": "react-router typegen && tsc", 24 | "typegen": "wrangler types", 25 | "auth:db:generate": "LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) dotenvx run -f .dev.vars -- npx @better-auth/cli generate --output=\"database/auth-schema.ts\" --y && prettier database/auth-schema.ts --write" 26 | }, 27 | "dependencies": { 28 | "@noxharmonium/kysely-d1": "0.4.0", 29 | "@react-router/node": "^7.1.1", 30 | "@react-router/serve": "^7.1.1", 31 | "better-auth": "^1.1.16", 32 | "drizzle-orm": "~0.36.4", 33 | "isbot": "^5.1.19", 34 | "kysely": "^0.27.5", 35 | "react": "^19.0.0", 36 | "react-dom": "^19.0.0", 37 | "react-router": "^7.1.1" 38 | }, 39 | "devDependencies": { 40 | "@cloudflare/workers-types": "^4.20241224.0", 41 | "@dotenvx/dotenvx": "^1.32.0", 42 | "@react-router/dev": "^7.1.1", 43 | "@types/node": "^20.17.10", 44 | "@types/react": "^19.0.2", 45 | "@types/react-dom": "^19.0.2", 46 | "autoprefixer": "^10.4.20", 47 | "better-sqlite3": "^11.7.0", 48 | "dotenv-cli": "^7.4.4", 49 | "drizzle-kit": "~0.28.1", 50 | "postcss": "^8.4.49", 51 | "prettier": "^3.4.2", 52 | "tailwindcss": "^3.4.17", 53 | "typescript": "^5.7.2", 54 | "vite": "^5.4.11", 55 | "vite-tsconfig-paths": "^5.1.4", 56 | "wrangler": "^3.99.0" 57 | }, 58 | "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf" 59 | } -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "db9421ef-6a5a-46ef-af81-d6ec1e2faa5d", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "account": { 8 | "name": "account", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "account_id": { 18 | "name": "account_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "provider_id": { 25 | "name": "provider_id", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "user_id": { 32 | "name": "user_id", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "access_token": { 39 | "name": "access_token", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "refresh_token": { 46 | "name": "refresh_token", 47 | "type": "text", 48 | "primaryKey": false, 49 | "notNull": false, 50 | "autoincrement": false 51 | }, 52 | "id_token": { 53 | "name": "id_token", 54 | "type": "text", 55 | "primaryKey": false, 56 | "notNull": false, 57 | "autoincrement": false 58 | }, 59 | "access_token_expires_at": { 60 | "name": "access_token_expires_at", 61 | "type": "integer", 62 | "primaryKey": false, 63 | "notNull": false, 64 | "autoincrement": false 65 | }, 66 | "refresh_token_expires_at": { 67 | "name": "refresh_token_expires_at", 68 | "type": "integer", 69 | "primaryKey": false, 70 | "notNull": false, 71 | "autoincrement": false 72 | }, 73 | "scope": { 74 | "name": "scope", 75 | "type": "text", 76 | "primaryKey": false, 77 | "notNull": false, 78 | "autoincrement": false 79 | }, 80 | "password": { 81 | "name": "password", 82 | "type": "text", 83 | "primaryKey": false, 84 | "notNull": false, 85 | "autoincrement": false 86 | }, 87 | "created_at": { 88 | "name": "created_at", 89 | "type": "integer", 90 | "primaryKey": false, 91 | "notNull": true, 92 | "autoincrement": false 93 | }, 94 | "updated_at": { 95 | "name": "updated_at", 96 | "type": "integer", 97 | "primaryKey": false, 98 | "notNull": true, 99 | "autoincrement": false 100 | } 101 | }, 102 | "indexes": {}, 103 | "foreignKeys": { 104 | "account_user_id_user_id_fk": { 105 | "name": "account_user_id_user_id_fk", 106 | "tableFrom": "account", 107 | "tableTo": "user", 108 | "columnsFrom": [ 109 | "user_id" 110 | ], 111 | "columnsTo": [ 112 | "id" 113 | ], 114 | "onDelete": "no action", 115 | "onUpdate": "no action" 116 | } 117 | }, 118 | "compositePrimaryKeys": {}, 119 | "uniqueConstraints": {}, 120 | "checkConstraints": {} 121 | }, 122 | "session": { 123 | "name": "session", 124 | "columns": { 125 | "id": { 126 | "name": "id", 127 | "type": "text", 128 | "primaryKey": true, 129 | "notNull": true, 130 | "autoincrement": false 131 | }, 132 | "expires_at": { 133 | "name": "expires_at", 134 | "type": "integer", 135 | "primaryKey": false, 136 | "notNull": true, 137 | "autoincrement": false 138 | }, 139 | "token": { 140 | "name": "token", 141 | "type": "text", 142 | "primaryKey": false, 143 | "notNull": true, 144 | "autoincrement": false 145 | }, 146 | "created_at": { 147 | "name": "created_at", 148 | "type": "integer", 149 | "primaryKey": false, 150 | "notNull": true, 151 | "autoincrement": false 152 | }, 153 | "updated_at": { 154 | "name": "updated_at", 155 | "type": "integer", 156 | "primaryKey": false, 157 | "notNull": true, 158 | "autoincrement": false 159 | }, 160 | "ip_address": { 161 | "name": "ip_address", 162 | "type": "text", 163 | "primaryKey": false, 164 | "notNull": false, 165 | "autoincrement": false 166 | }, 167 | "user_agent": { 168 | "name": "user_agent", 169 | "type": "text", 170 | "primaryKey": false, 171 | "notNull": false, 172 | "autoincrement": false 173 | }, 174 | "user_id": { 175 | "name": "user_id", 176 | "type": "text", 177 | "primaryKey": false, 178 | "notNull": true, 179 | "autoincrement": false 180 | } 181 | }, 182 | "indexes": { 183 | "session_token_unique": { 184 | "name": "session_token_unique", 185 | "columns": [ 186 | "token" 187 | ], 188 | "isUnique": true 189 | } 190 | }, 191 | "foreignKeys": { 192 | "session_user_id_user_id_fk": { 193 | "name": "session_user_id_user_id_fk", 194 | "tableFrom": "session", 195 | "tableTo": "user", 196 | "columnsFrom": [ 197 | "user_id" 198 | ], 199 | "columnsTo": [ 200 | "id" 201 | ], 202 | "onDelete": "no action", 203 | "onUpdate": "no action" 204 | } 205 | }, 206 | "compositePrimaryKeys": {}, 207 | "uniqueConstraints": {}, 208 | "checkConstraints": {} 209 | }, 210 | "user": { 211 | "name": "user", 212 | "columns": { 213 | "id": { 214 | "name": "id", 215 | "type": "text", 216 | "primaryKey": true, 217 | "notNull": true, 218 | "autoincrement": false 219 | }, 220 | "name": { 221 | "name": "name", 222 | "type": "text", 223 | "primaryKey": false, 224 | "notNull": true, 225 | "autoincrement": false 226 | }, 227 | "email": { 228 | "name": "email", 229 | "type": "text", 230 | "primaryKey": false, 231 | "notNull": true, 232 | "autoincrement": false 233 | }, 234 | "email_verified": { 235 | "name": "email_verified", 236 | "type": "integer", 237 | "primaryKey": false, 238 | "notNull": true, 239 | "autoincrement": false 240 | }, 241 | "image": { 242 | "name": "image", 243 | "type": "text", 244 | "primaryKey": false, 245 | "notNull": false, 246 | "autoincrement": false 247 | }, 248 | "created_at": { 249 | "name": "created_at", 250 | "type": "integer", 251 | "primaryKey": false, 252 | "notNull": true, 253 | "autoincrement": false 254 | }, 255 | "updated_at": { 256 | "name": "updated_at", 257 | "type": "integer", 258 | "primaryKey": false, 259 | "notNull": true, 260 | "autoincrement": false 261 | } 262 | }, 263 | "indexes": { 264 | "user_email_unique": { 265 | "name": "user_email_unique", 266 | "columns": [ 267 | "email" 268 | ], 269 | "isUnique": true 270 | } 271 | }, 272 | "foreignKeys": {}, 273 | "compositePrimaryKeys": {}, 274 | "uniqueConstraints": {}, 275 | "checkConstraints": {} 276 | }, 277 | "verification": { 278 | "name": "verification", 279 | "columns": { 280 | "id": { 281 | "name": "id", 282 | "type": "text", 283 | "primaryKey": true, 284 | "notNull": true, 285 | "autoincrement": false 286 | }, 287 | "identifier": { 288 | "name": "identifier", 289 | "type": "text", 290 | "primaryKey": false, 291 | "notNull": true, 292 | "autoincrement": false 293 | }, 294 | "value": { 295 | "name": "value", 296 | "type": "text", 297 | "primaryKey": false, 298 | "notNull": true, 299 | "autoincrement": false 300 | }, 301 | "expires_at": { 302 | "name": "expires_at", 303 | "type": "integer", 304 | "primaryKey": false, 305 | "notNull": true, 306 | "autoincrement": false 307 | }, 308 | "created_at": { 309 | "name": "created_at", 310 | "type": "integer", 311 | "primaryKey": false, 312 | "notNull": false, 313 | "autoincrement": false 314 | }, 315 | "updated_at": { 316 | "name": "updated_at", 317 | "type": "integer", 318 | "primaryKey": false, 319 | "notNull": false, 320 | "autoincrement": false 321 | } 322 | }, 323 | "indexes": {}, 324 | "foreignKeys": {}, 325 | "compositePrimaryKeys": {}, 326 | "uniqueConstraints": {}, 327 | "checkConstraints": {} 328 | } 329 | }, 330 | "views": {}, 331 | "enums": {}, 332 | "_meta": { 333 | "schemas": {}, 334 | "tables": {}, 335 | "columns": {} 336 | }, 337 | "internal": { 338 | "indexes": {} 339 | } 340 | } --------------------------------------------------------------------------------