├── .editorconfig ├── .env.example ├── .gitattributes ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── components.json ├── docker-compose.yml ├── drizzle.config.ts ├── eslint.config.js ├── package.json ├── public └── favicon.ico ├── src ├── components │ ├── DefaultCatchBoundary.tsx │ ├── NotFound.tsx │ ├── ThemeToggle.tsx │ └── ui │ │ ├── button.tsx │ │ ├── input.tsx │ │ └── label.tsx ├── lib │ ├── auth │ │ ├── auth-client.ts │ │ ├── functions │ │ │ └── getUser.ts │ │ ├── index.ts │ │ └── middleware │ │ │ └── auth-guard.ts │ ├── db │ │ ├── index.ts │ │ └── schema │ │ │ ├── auth.schema.ts │ │ │ └── index.ts │ └── utils.ts ├── routeTree.gen.ts ├── router.tsx ├── routes │ ├── (auth) │ │ ├── login.tsx │ │ ├── route.tsx │ │ └── signup.tsx │ ├── __root.tsx │ ├── api │ │ └── auth │ │ │ └── $.ts │ ├── dashboard │ │ ├── index.tsx │ │ └── route.tsx │ └── index.tsx └── styles.css ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | max_line_length = 90 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=http://localhost:3000 2 | 3 | DATABASE_URL="postgresql://user:password@localhost:5432/tanstarter" 4 | # You can also use Docker Compose to set up a local PostgreSQL database: 5 | # docker-compose up -d 6 | 7 | # pnpm run auth:secret 8 | BETTER_AUTH_SECRET= 9 | 10 | # OAuth2 Providers, optional 11 | GITHUB_CLIENT_ID= 12 | GITHUB_CLIENT_SECRET= 13 | GOOGLE_CLIENT_ID= 14 | GOOGLE_CLIENT_SECRET= 15 | 16 | # NOTE: 17 | # In your OAuth2 apps, set callback/redirect URIs to`http://localhost:3000/api/auth/callback/` 18 | # e.g. http://localhost:3000/api/auth/callback/github -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Ignore lockfiles we don't use 4 | # package-lock.json 5 | # yarn.lock 6 | # pnpm-lock.yaml 7 | # bun.lock 8 | 9 | .DS_Store 10 | .cache 11 | .env 12 | 13 | .data 14 | .vercel 15 | .output 16 | .wrangler 17 | .netlify 18 | dist 19 | /build/ 20 | /api/ 21 | /server/build 22 | /public/build 23 | 24 | .tanstack -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # lockfiles 2 | pnpm-lock.yaml 3 | package-lock.json 4 | yarn.lock 5 | bun.lock 6 | 7 | # misc 8 | routeTree.gen.ts 9 | .tanstack/ 10 | drizzle/ 11 | .drizzle/ 12 | 13 | # build outputs 14 | .vercel 15 | .output 16 | .wrangler 17 | .netlify 18 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "printWidth": 90, 5 | "singleQuote": false, 6 | "endOfLine": "lf", 7 | "trailingComma": "all", 8 | "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.readonlyInclude": { 3 | "**/routeTree.gen.ts": true, 4 | "**/.tanstack/**/*": true, 5 | "pnpm-lock.yaml": true, 6 | "bun.lock": true 7 | }, 8 | "files.watcherExclude": { 9 | "**/routeTree.gen.ts": true, 10 | "**/.tanstack/**/*": true, 11 | "pnpm-lock.yaml": true, 12 | "bun.lock": true 13 | }, 14 | "search.exclude": { 15 | "**/routeTree.gen.ts": true, 16 | "**/.tanstack/**/*": true, 17 | "pnpm-lock.yaml": true, 18 | "bun.lock": true 19 | }, 20 | 21 | "explorer.fileNesting.enabled": true, 22 | "explorer.fileNesting.patterns": { 23 | "tsconfig.json": "tsconfig.*.json, env.d.ts", 24 | "vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*", 25 | "package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .prettier*, prettier*, .editorconfig, .gitattributes, bun.lock" 26 | }, 27 | 28 | // always choose typescript from node_modules 29 | "typescript.tsdk": "./node_modules/typescript/lib", 30 | 31 | // use LF line endings 32 | "files.eol": "\n", 33 | 34 | // set prettier as default formatter for json, ts, tsx, js, jsx, html, css 35 | "[json][jsonc][typescript][typescriptreact][javascript][javascriptreact][html][css]": { 36 | "editor.defaultFormatter": "esbenp.prettier-vscode" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | // This license applies only to the original template in https://github.com/dotnize/react-tanstarter 2 | // If you're using it to start your own project, feel free to remove or replace this file. 3 | 4 | This is free and unencumbered software released into the public domain. 5 | 6 | Anyone is free to copy, modify, publish, use, compile, sell, or 7 | distribute this software, either in source code form or as a compiled 8 | binary, for any purpose, commercial or non-commercial, and by any 9 | means. 10 | 11 | In jurisdictions that recognize copyright laws, the author or authors 12 | of this software dedicate any and all copyright interest in the 13 | software to the public domain. We make this dedication for the benefit 14 | of the public at large and to the detriment of our heirs and 15 | successors. We intend this dedication to be an overt act of 16 | relinquishment in perpetuity of all present and future rights to this 17 | software under copyright law. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 22 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 23 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 24 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | For more information, please refer to 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [React TanStarter](https://github.com/dotnize/react-tanstarter) 2 | 3 | A minimal starter template for 🏝️ TanStack Start. [→ Preview here](https://tanstarter.nize.ph/) 4 | 5 | - [React 19](https://react.dev) + [React Compiler](https://react.dev/learn/react-compiler) 6 | - TanStack [Start](https://tanstack.com/start/latest) + [Router](https://tanstack.com/router/latest) + [Query](https://tanstack.com/query/latest) 7 | - [Tailwind CSS v4](https://tailwindcss.com/) + [shadcn/ui](https://ui.shadcn.com/) 8 | - [Drizzle ORM](https://orm.drizzle.team/) + PostgreSQL 9 | - [Better Auth](https://www.better-auth.com/) 10 | 11 | ## Getting Started 12 | 13 | 1. [Use this template](https://github.com/new?template_name=react-tanstarter&template_owner=dotnize) or clone this repository with gitpick: 14 | 15 | ```bash 16 | npx gitpick dotnize/react-tanstarter myapp 17 | cd myapp 18 | ``` 19 | 20 | 2. Install dependencies: 21 | 22 | ```bash 23 | pnpm install 24 | ``` 25 | 26 | 3. Create a `.env` file based on [`.env.example`](./.env.example). 27 | 28 | 4. Push the schema to your database with drizzle-kit: 29 | 30 | ```bash 31 | pnpm db push 32 | ``` 33 | 34 | https://orm.drizzle.team/docs/migrations 35 | 36 | 5. Run the development server: 37 | 38 | ```bash 39 | pnpm dev 40 | ``` 41 | 42 | The development server should now be running at [http://localhost:3000](http://localhost:3000). 43 | 44 | ## Issue watchlist 45 | 46 | - [React Compiler docs](https://react.dev/learn/react-compiler), [Working Group](https://github.com/reactwg/react-compiler/discussions) - React Compiler is in RC. 47 | - https://github.com/TanStack/router/discussions/2863 - TanStack Start is in beta may still undergo major changes. 48 | 49 | ## Goodies 50 | 51 | #### Scripts 52 | 53 | These scripts in [package.json](./package.json#L5) use **pnpm** by default, but you can modify them to use your preferred package manager. 54 | 55 | - **`auth:generate`** - Regenerate the [auth db schema](./src/lib/db/schema/auth.schema.ts) if you've made changes to your Better Auth [config](./src/lib/auth/index.ts). 56 | - **`db`** - Run drizzle-kit commands. (e.g. `pnpm db generate` to generate a migration) 57 | - **`ui`** - The shadcn/ui CLI. (e.g. `pnpm ui add button` to add the button component) 58 | - **`format`** and **`lint`** - Run Prettier and ESLint. 59 | - **`deps`** - Selectively upgrade dependencies via npm-check-updates. 60 | 61 | #### Utilities 62 | 63 | - [`auth-guard.ts`](./src/lib/auth/middleware/auth-guard.ts) - Sample middleware for forcing authentication on server functions. (see [#5](https://github.com/dotnize/react-tanstarter/issues/5#issuecomment-2615905686) and [#17](https://github.com/dotnize/react-tanstarter/issues/17#issuecomment-2853482062)) 64 | - [`ThemeToggle.tsx`](./src/components/ThemeToggle.tsx) - A simple component to toggle between light and dark mode. ([#7](https://github.com/dotnize/react-tanstarter/issues/7)) 65 | 66 | ## Building for production 67 | 68 | Read the [hosting docs](https://tanstack.com/start/latest/docs/framework/react/hosting) for information on how to deploy your TanStack Start app. 69 | 70 | ## License 71 | 72 | Code in this template is public domain via [Unlicense](./LICENSE). Feel free to remove or replace for your own project. 73 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/styles.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils", 16 | "ui": "~/components/ui", 17 | "lib": "~/lib", 18 | "hooks": "~/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:alpine 4 | ports: 5 | - 5432:5432 6 | volumes: 7 | - postgres_data_tanstarter:/var/lib/postgresql/data 8 | environment: 9 | - POSTGRES_USER=user 10 | - POSTGRES_PASSWORD=password 11 | - POSTGRES_DB=tanstarter 12 | 13 | volumes: 14 | postgres_data_tanstarter: 15 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | out: "./drizzle", 5 | schema: "./src/lib/db/schema/index.ts", 6 | breakpoints: true, 7 | verbose: true, 8 | strict: true, 9 | dialect: "postgresql", 10 | casing: "snake_case", 11 | dbCredentials: { 12 | url: process.env.DATABASE_URL as string, 13 | }, 14 | } satisfies Config; 15 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import react from "@eslint-react/eslint-plugin"; 2 | import js from "@eslint/js"; 3 | import pluginQuery from "@tanstack/eslint-plugin-query"; 4 | import pluginRouter from "@tanstack/eslint-plugin-router"; 5 | import eslintConfigPrettier from "eslint-config-prettier"; 6 | import * as reactHooks from "eslint-plugin-react-hooks"; 7 | import tseslint from "typescript-eslint"; 8 | 9 | export default tseslint.config({ 10 | ignores: ["dist", ".wrangler", ".vercel", ".netlify", ".output", "build/"], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | parser: tseslint.parser, 14 | parserOptions: { 15 | projectService: true, 16 | tsconfigRootDir: import.meta.dirname, 17 | }, 18 | }, 19 | extends: [ 20 | js.configs.recommended, 21 | ...tseslint.configs.recommended, 22 | eslintConfigPrettier, 23 | ...pluginQuery.configs["flat/recommended"], 24 | ...pluginRouter.configs["flat/recommended"], 25 | reactHooks.configs.recommended, 26 | react.configs["recommended-type-checked"], 27 | // ...you can add plugins or configs here 28 | ], 29 | rules: { 30 | // You can override any rules here 31 | "react-hooks/react-compiler": "warn", 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tanstarter", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite dev --port 3000", 7 | "build": "vite build", 8 | "start": "node .output/server/index.mjs", 9 | "lint": "eslint .", 10 | "check-types": "tsc --noEmit", 11 | "format": "prettier --write .", 12 | "db": "drizzle-kit", 13 | "deps": "pnpm dlx npm-check-updates@latest --interactive --format group", 14 | "ui": "pnpm dlx shadcn@latest", 15 | "auth:secret": "pnpm dlx @better-auth/cli@latest secret", 16 | "auth:generate": "pnpm dlx @better-auth/cli@latest generate --config ./src/lib/auth/index.ts --y --output ./src/lib/db/schema/auth.schema.ts && prettier --write ./src/lib/db/schema/auth.schema.ts" 17 | }, 18 | "dependencies": { 19 | "@radix-ui/react-label": "^2.1.7", 20 | "@radix-ui/react-slot": "^1.2.3", 21 | "@tanstack/react-query": "^5.80.6", 22 | "@tanstack/react-query-devtools": "^5.80.6", 23 | "@tanstack/react-router": "^1.121.0", 24 | "@tanstack/react-router-devtools": "^1.121.0", 25 | "@tanstack/react-router-with-query": "^1.121.0", 26 | "@tanstack/react-start": "^1.121.0", 27 | "better-auth": "^1.2.9", 28 | "class-variance-authority": "^0.7.1", 29 | "clsx": "^2.1.1", 30 | "drizzle-orm": "^0.44.2", 31 | "lucide-react": "^0.514.0", 32 | "postgres": "^3.4.7", 33 | "react": "^19.1.0", 34 | "react-dom": "^19.1.0", 35 | "tailwind-merge": "^3.3.1", 36 | "vite": "^6.3.5" 37 | }, 38 | "devDependencies": { 39 | "@eslint-react/eslint-plugin": "^1.51.3", 40 | "@eslint/js": "^9.28.0", 41 | "@tailwindcss/vite": "^4.1.8", 42 | "@tanstack/eslint-plugin-query": "^5.78.0", 43 | "@tanstack/eslint-plugin-router": "^1.120.17", 44 | "@types/node": "^24.0.0", 45 | "@types/react": "^19.1.7", 46 | "@types/react-dom": "^19.1.6", 47 | "babel-plugin-react-compiler": "latest", 48 | "drizzle-kit": "^0.31.1", 49 | "eslint": "^9.28.0", 50 | "eslint-config-prettier": "^10.1.5", 51 | "eslint-plugin-react-hooks": "6.0.0-rc.1", 52 | "prettier": "^3.5.3", 53 | "prettier-plugin-organize-imports": "^4.1.0", 54 | "prettier-plugin-tailwindcss": "^0.6.12", 55 | "tailwindcss": "^4.1.8", 56 | "tw-animate-css": "^1.3.4", 57 | "typescript": "^5.8.3", 58 | "typescript-eslint": "^8.34.0", 59 | "vite-tsconfig-paths": "^5.1.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnize/react-tanstarter/f1a55cd95db0dad41cc76b3795a1e48d2b09a2c2/public/favicon.ico -------------------------------------------------------------------------------- /src/components/DefaultCatchBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ErrorComponent, 3 | type ErrorComponentProps, 4 | Link, 5 | rootRouteId, 6 | useMatch, 7 | useRouter, 8 | } from "@tanstack/react-router"; 9 | import { Button } from "./ui/button"; 10 | 11 | export function DefaultCatchBoundary({ error }: Readonly) { 12 | const router = useRouter(); 13 | const isRoot = useMatch({ 14 | strict: false, 15 | select: (state) => state.id === rootRouteId, 16 | }); 17 | 18 | console.error(error); 19 | 20 | return ( 21 |
22 | 23 |
24 | 32 | {isRoot ? ( 33 | 36 | ) : ( 37 | 48 | )} 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { Button } from "./ui/button"; 3 | 4 | export function NotFound() { 5 | return ( 6 |
7 |

The page you are looking for does not exist.

8 |

9 | 12 | 15 |

16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { MoonIcon, SunIcon } from "lucide-react"; 2 | import { Button } from "~/components/ui/button"; 3 | 4 | export default function ThemeToggle() { 5 | function toggleTheme() { 6 | if ( 7 | document.documentElement.classList.contains("dark") || 8 | (!("theme" in localStorage) && 9 | window.matchMedia("(prefers-color-scheme: dark)").matches) 10 | ) { 11 | document.documentElement.classList.remove("dark"); 12 | localStorage.theme = "light"; 13 | } else { 14 | document.documentElement.classList.add("dark"); 15 | localStorage.theme = "dark"; 16 | } 17 | } 18 | 19 | return ( 20 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 15 | outline: 16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 17 | secondary: 18 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 24 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 25 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 26 | icon: "size-9", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | function Button({ 37 | className, 38 | variant, 39 | size, 40 | asChild = false, 41 | ...props 42 | }: React.ComponentProps<"button"> & 43 | VariantProps & { 44 | asChild?: boolean; 45 | }) { 46 | const Comp = asChild ? Slot : "button"; 47 | 48 | return ( 49 | 54 | ); 55 | } 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "~/lib/utils"; 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ); 19 | } 20 | 21 | export { Input }; 22 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | function Label({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ); 20 | } 21 | 22 | export { Label }; 23 | -------------------------------------------------------------------------------- /src/lib/auth/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | 3 | const authClient = createAuthClient({ 4 | baseURL: import.meta.env.VITE_BASE_URL, 5 | }); 6 | 7 | export default authClient; 8 | -------------------------------------------------------------------------------- /src/lib/auth/functions/getUser.ts: -------------------------------------------------------------------------------- 1 | import { createServerFn } from "@tanstack/react-start"; 2 | import { getWebRequest } from "@tanstack/react-start/server"; 3 | import { auth } from "~/lib/auth"; 4 | 5 | export const getUser = createServerFn({ method: "GET" }).handler(async () => { 6 | const { headers } = getWebRequest(); 7 | const session = await auth.api.getSession({ headers }); 8 | 9 | return session?.user || null; 10 | }); 11 | -------------------------------------------------------------------------------- /src/lib/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { serverOnly } from "@tanstack/react-start"; 2 | import { betterAuth } from "better-auth"; 3 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 4 | import { reactStartCookies } from "better-auth/react-start"; 5 | 6 | import { db } from "~/lib/db"; 7 | 8 | const getAuthConfig = serverOnly(() => 9 | betterAuth({ 10 | baseURL: process.env.VITE_BASE_URL, 11 | database: drizzleAdapter(db, { 12 | provider: "pg", 13 | }), 14 | 15 | // https://www.better-auth.com/docs/integrations/tanstack#usage-tips 16 | plugins: [reactStartCookies()], 17 | 18 | // https://www.better-auth.com/docs/concepts/session-management#session-caching 19 | // session: { 20 | // cookieCache: { 21 | // enabled: true, 22 | // maxAge: 5 * 60, // 5 minutes 23 | // }, 24 | // }, 25 | 26 | // https://www.better-auth.com/docs/concepts/oauth 27 | socialProviders: { 28 | github: { 29 | clientId: process.env.GITHUB_CLIENT_ID!, 30 | clientSecret: process.env.GITHUB_CLIENT_SECRET!, 31 | }, 32 | google: { 33 | clientId: process.env.GOOGLE_CLIENT_ID!, 34 | clientSecret: process.env.GOOGLE_CLIENT_SECRET!, 35 | }, 36 | }, 37 | 38 | // https://www.better-auth.com/docs/authentication/email-password 39 | emailAndPassword: { 40 | enabled: true, 41 | }, 42 | }), 43 | ); 44 | 45 | export const auth = getAuthConfig(); 46 | -------------------------------------------------------------------------------- /src/lib/auth/middleware/auth-guard.ts: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from "@tanstack/react-start"; 2 | import { getWebRequest, setResponseStatus } from "@tanstack/react-start/server"; 3 | import { auth } from "~/lib/auth"; 4 | 5 | // https://tanstack.com/start/latest/docs/framework/react/middleware 6 | // This is a sample middleware that you can use in your server functions. 7 | 8 | /** 9 | * Middleware to force authentication on a server function, and add the user to the context. 10 | */ 11 | export const authMiddleware = createMiddleware({ type: "function" }).server( 12 | async ({ next }) => { 13 | const { headers } = getWebRequest(); 14 | 15 | const session = await auth.api.getSession({ 16 | headers, 17 | query: { 18 | // ensure session is fresh 19 | // https://www.better-auth.com/docs/concepts/session-management#session-caching 20 | disableCookieCache: true, 21 | }, 22 | }); 23 | 24 | if (!session) { 25 | setResponseStatus(401); 26 | throw new Error("Unauthorized"); 27 | } 28 | 29 | return next({ context: { user: session.user } }); 30 | }, 31 | ); 32 | -------------------------------------------------------------------------------- /src/lib/db/index.ts: -------------------------------------------------------------------------------- 1 | import { serverOnly } from "@tanstack/react-start"; 2 | import { drizzle } from "drizzle-orm/postgres-js"; 3 | import postgres from "postgres"; 4 | 5 | import * as schema from "~/lib/db/schema"; 6 | 7 | const driver = postgres(process.env.DATABASE_URL as string); 8 | 9 | const getDatabase = serverOnly(() => 10 | drizzle({ client: driver, schema, casing: "snake_case" }), 11 | ); 12 | 13 | export const db = getDatabase(); 14 | -------------------------------------------------------------------------------- /src/lib/db/schema/auth.schema.ts: -------------------------------------------------------------------------------- 1 | import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 2 | 3 | export const user = pgTable("user", { 4 | id: text("id").primaryKey(), 5 | name: text("name").notNull(), 6 | email: text("email").notNull().unique(), 7 | emailVerified: boolean("email_verified") 8 | .$defaultFn(() => false) 9 | .notNull(), 10 | image: text("image"), 11 | createdAt: timestamp("created_at") 12 | .$defaultFn(() => /* @__PURE__ */ new Date()) 13 | .notNull(), 14 | updatedAt: timestamp("updated_at") 15 | .$defaultFn(() => /* @__PURE__ */ new Date()) 16 | .notNull(), 17 | }); 18 | 19 | export const session = pgTable("session", { 20 | id: text("id").primaryKey(), 21 | expiresAt: timestamp("expires_at").notNull(), 22 | token: text("token").notNull().unique(), 23 | createdAt: timestamp("created_at").notNull(), 24 | updatedAt: timestamp("updated_at").notNull(), 25 | ipAddress: text("ip_address"), 26 | userAgent: text("user_agent"), 27 | userId: text("user_id") 28 | .notNull() 29 | .references(() => user.id, { onDelete: "cascade" }), 30 | }); 31 | 32 | export const account = pgTable("account", { 33 | id: text("id").primaryKey(), 34 | accountId: text("account_id").notNull(), 35 | providerId: text("provider_id").notNull(), 36 | userId: text("user_id") 37 | .notNull() 38 | .references(() => user.id, { onDelete: "cascade" }), 39 | accessToken: text("access_token"), 40 | refreshToken: text("refresh_token"), 41 | idToken: text("id_token"), 42 | accessTokenExpiresAt: timestamp("access_token_expires_at"), 43 | refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), 44 | scope: text("scope"), 45 | password: text("password"), 46 | createdAt: timestamp("created_at").notNull(), 47 | updatedAt: timestamp("updated_at").notNull(), 48 | }); 49 | 50 | export const verification = pgTable("verification", { 51 | id: text("id").primaryKey(), 52 | identifier: text("identifier").notNull(), 53 | value: text("value").notNull(), 54 | expiresAt: timestamp("expires_at").notNull(), 55 | createdAt: timestamp("created_at").$defaultFn(() => /* @__PURE__ */ new Date()), 56 | updatedAt: timestamp("updated_at").$defaultFn(() => /* @__PURE__ */ new Date()), 57 | }); 58 | -------------------------------------------------------------------------------- /src/lib/db/schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./auth.schema"; 2 | // export your other schemas here 3 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | import { createServerRootRoute } from "@tanstack/react-start/server"; 12 | 13 | import { Route as rootRouteImport } from "./routes/__root"; 14 | import { Route as DashboardRouteRouteImport } from "./routes/dashboard/route"; 15 | import { Route as authRouteRouteImport } from "./routes/(auth)/route"; 16 | import { Route as IndexRouteImport } from "./routes/index"; 17 | import { Route as DashboardIndexRouteImport } from "./routes/dashboard/index"; 18 | import { Route as authSignupRouteImport } from "./routes/(auth)/signup"; 19 | import { Route as authLoginRouteImport } from "./routes/(auth)/login"; 20 | import { ServerRoute as ApiAuthSplatServerRouteImport } from "./routes/api/auth/$"; 21 | 22 | const rootServerRouteImport = createServerRootRoute(); 23 | 24 | const DashboardRouteRoute = DashboardRouteRouteImport.update({ 25 | id: "/dashboard", 26 | path: "/dashboard", 27 | getParentRoute: () => rootRouteImport, 28 | } as any); 29 | const authRouteRoute = authRouteRouteImport.update({ 30 | id: "/(auth)", 31 | getParentRoute: () => rootRouteImport, 32 | } as any); 33 | const IndexRoute = IndexRouteImport.update({ 34 | id: "/", 35 | path: "/", 36 | getParentRoute: () => rootRouteImport, 37 | } as any); 38 | const DashboardIndexRoute = DashboardIndexRouteImport.update({ 39 | id: "/", 40 | path: "/", 41 | getParentRoute: () => DashboardRouteRoute, 42 | } as any); 43 | const authSignupRoute = authSignupRouteImport.update({ 44 | id: "/signup", 45 | path: "/signup", 46 | getParentRoute: () => authRouteRoute, 47 | } as any); 48 | const authLoginRoute = authLoginRouteImport.update({ 49 | id: "/login", 50 | path: "/login", 51 | getParentRoute: () => authRouteRoute, 52 | } as any); 53 | const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({ 54 | id: "/api/auth/$", 55 | path: "/api/auth/$", 56 | getParentRoute: () => rootServerRouteImport, 57 | } as any); 58 | 59 | export interface FileRoutesByFullPath { 60 | "/": typeof authRouteRouteWithChildren; 61 | "/dashboard": typeof DashboardRouteRouteWithChildren; 62 | "/login": typeof authLoginRoute; 63 | "/signup": typeof authSignupRoute; 64 | "/dashboard/": typeof DashboardIndexRoute; 65 | } 66 | export interface FileRoutesByTo { 67 | "/": typeof authRouteRouteWithChildren; 68 | "/login": typeof authLoginRoute; 69 | "/signup": typeof authSignupRoute; 70 | "/dashboard": typeof DashboardIndexRoute; 71 | } 72 | export interface FileRoutesById { 73 | __root__: typeof rootRouteImport; 74 | "/": typeof IndexRoute; 75 | "/(auth)": typeof authRouteRouteWithChildren; 76 | "/dashboard": typeof DashboardRouteRouteWithChildren; 77 | "/(auth)/login": typeof authLoginRoute; 78 | "/(auth)/signup": typeof authSignupRoute; 79 | "/dashboard/": typeof DashboardIndexRoute; 80 | } 81 | export interface FileRouteTypes { 82 | fileRoutesByFullPath: FileRoutesByFullPath; 83 | fullPaths: "/" | "/dashboard" | "/login" | "/signup" | "/dashboard/"; 84 | fileRoutesByTo: FileRoutesByTo; 85 | to: "/" | "/login" | "/signup" | "/dashboard"; 86 | id: 87 | | "__root__" 88 | | "/" 89 | | "/(auth)" 90 | | "/dashboard" 91 | | "/(auth)/login" 92 | | "/(auth)/signup" 93 | | "/dashboard/"; 94 | fileRoutesById: FileRoutesById; 95 | } 96 | export interface RootRouteChildren { 97 | IndexRoute: typeof IndexRoute; 98 | authRouteRoute: typeof authRouteRouteWithChildren; 99 | DashboardRouteRoute: typeof DashboardRouteRouteWithChildren; 100 | } 101 | export interface FileServerRoutesByFullPath { 102 | "/api/auth/$": typeof ApiAuthSplatServerRoute; 103 | } 104 | export interface FileServerRoutesByTo { 105 | "/api/auth/$": typeof ApiAuthSplatServerRoute; 106 | } 107 | export interface FileServerRoutesById { 108 | __root__: typeof rootServerRouteImport; 109 | "/api/auth/$": typeof ApiAuthSplatServerRoute; 110 | } 111 | export interface FileServerRouteTypes { 112 | fileServerRoutesByFullPath: FileServerRoutesByFullPath; 113 | fullPaths: "/api/auth/$"; 114 | fileServerRoutesByTo: FileServerRoutesByTo; 115 | to: "/api/auth/$"; 116 | id: "__root__" | "/api/auth/$"; 117 | fileServerRoutesById: FileServerRoutesById; 118 | } 119 | export interface RootServerRouteChildren { 120 | ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute; 121 | } 122 | 123 | declare module "@tanstack/react-router" { 124 | interface FileRoutesByPath { 125 | "/": { 126 | id: "/"; 127 | path: "/"; 128 | fullPath: "/"; 129 | preLoaderRoute: typeof IndexRouteImport; 130 | parentRoute: typeof rootRouteImport; 131 | }; 132 | "/(auth)": { 133 | id: "/(auth)"; 134 | path: "/"; 135 | fullPath: "/"; 136 | preLoaderRoute: typeof authRouteRouteImport; 137 | parentRoute: typeof rootRouteImport; 138 | }; 139 | "/dashboard": { 140 | id: "/dashboard"; 141 | path: "/dashboard"; 142 | fullPath: "/dashboard"; 143 | preLoaderRoute: typeof DashboardRouteRouteImport; 144 | parentRoute: typeof rootRouteImport; 145 | }; 146 | "/(auth)/login": { 147 | id: "/(auth)/login"; 148 | path: "/login"; 149 | fullPath: "/login"; 150 | preLoaderRoute: typeof authLoginRouteImport; 151 | parentRoute: typeof authRouteRoute; 152 | }; 153 | "/(auth)/signup": { 154 | id: "/(auth)/signup"; 155 | path: "/signup"; 156 | fullPath: "/signup"; 157 | preLoaderRoute: typeof authSignupRouteImport; 158 | parentRoute: typeof authRouteRoute; 159 | }; 160 | "/dashboard/": { 161 | id: "/dashboard/"; 162 | path: "/"; 163 | fullPath: "/dashboard/"; 164 | preLoaderRoute: typeof DashboardIndexRouteImport; 165 | parentRoute: typeof DashboardRouteRoute; 166 | }; 167 | "/api/auth/$": { 168 | id: "/api/auth/$"; 169 | path: ""; 170 | fullPath: "/api/auth/$"; 171 | preLoaderRoute: unknown; 172 | parentRoute: typeof rootRouteImport; 173 | }; 174 | } 175 | } 176 | declare module "@tanstack/react-start/server" { 177 | interface ServerFileRoutesByPath { 178 | "/": { 179 | id: "/"; 180 | path: "/"; 181 | fullPath: "/"; 182 | preLoaderRoute: unknown; 183 | parentRoute: typeof rootServerRouteImport; 184 | }; 185 | "/(auth)": { 186 | id: "/(auth)"; 187 | path: "/"; 188 | fullPath: "/"; 189 | preLoaderRoute: unknown; 190 | parentRoute: typeof rootServerRouteImport; 191 | }; 192 | "/dashboard": { 193 | id: "/dashboard"; 194 | path: "/dashboard"; 195 | fullPath: "/dashboard"; 196 | preLoaderRoute: unknown; 197 | parentRoute: typeof rootServerRouteImport; 198 | }; 199 | "/(auth)/login": { 200 | id: "/(auth)/login"; 201 | path: "/login"; 202 | fullPath: "/login"; 203 | preLoaderRoute: unknown; 204 | parentRoute: typeof rootServerRouteImport; 205 | }; 206 | "/(auth)/signup": { 207 | id: "/(auth)/signup"; 208 | path: "/signup"; 209 | fullPath: "/signup"; 210 | preLoaderRoute: unknown; 211 | parentRoute: typeof rootServerRouteImport; 212 | }; 213 | "/dashboard/": { 214 | id: "/dashboard/"; 215 | path: "/"; 216 | fullPath: "/dashboard/"; 217 | preLoaderRoute: unknown; 218 | parentRoute: typeof rootServerRouteImport; 219 | }; 220 | "/api/auth/$": { 221 | id: "/api/auth/$"; 222 | path: "/api/auth/$"; 223 | fullPath: "/api/auth/$"; 224 | preLoaderRoute: typeof ApiAuthSplatServerRouteImport; 225 | parentRoute: typeof rootServerRouteImport; 226 | }; 227 | } 228 | } 229 | 230 | interface authRouteRouteChildren { 231 | authLoginRoute: typeof authLoginRoute; 232 | authSignupRoute: typeof authSignupRoute; 233 | } 234 | 235 | const authRouteRouteChildren: authRouteRouteChildren = { 236 | authLoginRoute: authLoginRoute, 237 | authSignupRoute: authSignupRoute, 238 | }; 239 | 240 | const authRouteRouteWithChildren = authRouteRoute._addFileChildren( 241 | authRouteRouteChildren, 242 | ); 243 | 244 | interface DashboardRouteRouteChildren { 245 | DashboardIndexRoute: typeof DashboardIndexRoute; 246 | } 247 | 248 | const DashboardRouteRouteChildren: DashboardRouteRouteChildren = { 249 | DashboardIndexRoute: DashboardIndexRoute, 250 | }; 251 | 252 | const DashboardRouteRouteWithChildren = DashboardRouteRoute._addFileChildren( 253 | DashboardRouteRouteChildren, 254 | ); 255 | 256 | const rootRouteChildren: RootRouteChildren = { 257 | IndexRoute: IndexRoute, 258 | authRouteRoute: authRouteRouteWithChildren, 259 | DashboardRouteRoute: DashboardRouteRouteWithChildren, 260 | }; 261 | export const routeTree = rootRouteImport 262 | ._addFileChildren(rootRouteChildren) 263 | ._addFileTypes(); 264 | const rootServerRouteChildren: RootServerRouteChildren = { 265 | ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, 266 | }; 267 | export const serverRouteTree = rootServerRouteImport 268 | ._addFileChildren(rootServerRouteChildren) 269 | ._addFileTypes(); 270 | -------------------------------------------------------------------------------- /src/router.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { createRouter as createTanStackRouter } from "@tanstack/react-router"; 3 | import { routerWithQueryClient } from "@tanstack/react-router-with-query"; 4 | 5 | import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 6 | import { NotFound } from "~/components/NotFound"; 7 | import { routeTree } from "./routeTree.gen"; 8 | 9 | export function createRouter() { 10 | const queryClient = new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | refetchOnWindowFocus: false, 14 | staleTime: 1000 * 60 * 2, // 2 minutes 15 | }, 16 | }, 17 | }); 18 | 19 | return routerWithQueryClient( 20 | createTanStackRouter({ 21 | routeTree, 22 | context: { queryClient, user: null }, 23 | defaultPreload: "intent", 24 | // react-query will handle data fetching & caching 25 | // https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#passing-all-loader-events-to-an-external-cache 26 | defaultPreloadStaleTime: 0, 27 | defaultErrorComponent: DefaultCatchBoundary, 28 | defaultNotFoundComponent: NotFound, 29 | scrollRestoration: true, 30 | defaultStructuralSharing: true, 31 | }), 32 | queryClient, 33 | ); 34 | } 35 | 36 | declare module "@tanstack/react-router" { 37 | interface Register { 38 | router: ReturnType; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/routes/(auth)/login.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "@tanstack/react-query"; 2 | import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 3 | import { GalleryVerticalEnd, LoaderCircle } from "lucide-react"; 4 | import { useState } from "react"; 5 | import { Button } from "~/components/ui/button"; 6 | import { Input } from "~/components/ui/input"; 7 | import { Label } from "~/components/ui/label"; 8 | import authClient from "~/lib/auth/auth-client"; 9 | 10 | export const Route = createFileRoute("/(auth)/login")({ 11 | component: LoginForm, 12 | }); 13 | 14 | function LoginForm() { 15 | const { redirectUrl } = Route.useRouteContext(); 16 | const queryClient = useQueryClient(); 17 | const navigate = useNavigate(); 18 | 19 | const [isLoading, setIsLoading] = useState(false); 20 | const [errorMessage, setErrorMessage] = useState(""); 21 | 22 | const handleSubmit = (e: React.FormEvent) => { 23 | e.preventDefault(); 24 | if (isLoading) return; 25 | 26 | const formData = new FormData(e.currentTarget); 27 | const email = formData.get("email") as string; 28 | const password = formData.get("password") as string; 29 | if (!email || !password) return; 30 | 31 | setIsLoading(true); 32 | setErrorMessage(""); 33 | 34 | authClient.signIn.email( 35 | { 36 | email, 37 | password, 38 | callbackURL: redirectUrl, 39 | }, 40 | { 41 | onError: (ctx) => { 42 | setErrorMessage(ctx.error.message); 43 | setIsLoading(false); 44 | }, 45 | onSuccess: async () => { 46 | await queryClient.invalidateQueries({ queryKey: ["user"] }); 47 | navigate({ to: redirectUrl }); 48 | }, 49 | }, 50 | ); 51 | }; 52 | 53 | return ( 54 |
55 |
56 |
57 |
58 | 59 |
60 | 61 |
62 | Acme Inc. 63 |
64 |

Welcome back to Acme Inc.

65 |
66 |
67 |
68 | 69 | 77 |
78 |
79 | 80 | 88 |
89 | 93 |
94 | {errorMessage && ( 95 | {errorMessage} 96 | )} 97 |
98 | 99 | Or 100 | 101 |
102 |
103 | 135 | 167 |
168 |
169 |
170 | 171 |
172 | Don't have an account?{" "} 173 | 174 | Sign up 175 | 176 |
177 |
178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /src/routes/(auth)/route.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; 2 | 3 | export const Route = createFileRoute("/(auth)")({ 4 | component: RouteComponent, 5 | beforeLoad: async ({ context }) => { 6 | const REDIRECT_URL = "/dashboard"; 7 | if (context.user) { 8 | throw redirect({ 9 | to: REDIRECT_URL, 10 | }); 11 | } 12 | return { 13 | redirectUrl: REDIRECT_URL, 14 | }; 15 | }, 16 | }); 17 | 18 | function RouteComponent() { 19 | return ( 20 |
21 |
22 | 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/routes/(auth)/signup.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "@tanstack/react-query"; 2 | import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 3 | import { GalleryVerticalEnd, LoaderCircle } from "lucide-react"; 4 | import { useState } from "react"; 5 | import { Button } from "~/components/ui/button"; 6 | import { Input } from "~/components/ui/input"; 7 | import { Label } from "~/components/ui/label"; 8 | import authClient from "~/lib/auth/auth-client"; 9 | 10 | export const Route = createFileRoute("/(auth)/signup")({ 11 | component: SignupForm, 12 | }); 13 | 14 | function SignupForm() { 15 | const { redirectUrl } = Route.useRouteContext(); 16 | const queryClient = useQueryClient(); 17 | const navigate = useNavigate(); 18 | 19 | const [isLoading, setIsLoading] = useState(false); 20 | const [errorMessage, setErrorMessage] = useState(""); 21 | 22 | const handleSubmit = (e: React.FormEvent) => { 23 | e.preventDefault(); 24 | if (isLoading) return; 25 | 26 | const formData = new FormData(e.currentTarget); 27 | const name = formData.get("name") as string; 28 | const email = formData.get("email") as string; 29 | const password = formData.get("password") as string; 30 | const confirmPassword = formData.get("confirm_password") as string; 31 | 32 | if (!name || !email || !password || !confirmPassword) return; 33 | 34 | if (password !== confirmPassword) { 35 | setErrorMessage("Passwords do not match"); 36 | return; 37 | } 38 | 39 | setIsLoading(true); 40 | setErrorMessage(""); 41 | 42 | authClient.signUp.email( 43 | { 44 | name, 45 | email, 46 | password, 47 | callbackURL: redirectUrl, 48 | }, 49 | { 50 | onError: (ctx) => { 51 | setErrorMessage(ctx.error.message); 52 | setIsLoading(false); 53 | }, 54 | onSuccess: async () => { 55 | await queryClient.invalidateQueries({ queryKey: ["user"] }); 56 | navigate({ to: redirectUrl }); 57 | }, 58 | }, 59 | ); 60 | }; 61 | 62 | return ( 63 |
64 |
65 |
66 |
67 | 68 |
69 | 70 |
71 | Acme Inc. 72 |
73 |

Sign up for Acme Inc.

74 |
75 |
76 |
77 | 78 | 86 |
87 |
88 | 89 | 97 |
98 |
99 | 100 | 108 |
109 |
110 | 111 | 119 |
120 | 124 |
125 | {errorMessage && ( 126 | {errorMessage} 127 | )} 128 |
129 | 130 | Or 131 | 132 |
133 |
134 | 166 | 198 |
199 |
200 |
201 | 202 |
203 | Already have an account?{" "} 204 | 205 | Login 206 | 207 |
208 |
209 | ); 210 | } 211 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import type { QueryClient } from "@tanstack/react-query"; 3 | import { 4 | createRootRouteWithContext, 5 | HeadContent, 6 | Outlet, 7 | ScriptOnce, 8 | Scripts, 9 | } from "@tanstack/react-router"; 10 | 11 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 12 | import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 13 | 14 | import { getUser } from "~/lib/auth/functions/getUser"; 15 | import appCss from "~/styles.css?url"; 16 | 17 | export const Route = createRootRouteWithContext<{ 18 | queryClient: QueryClient; 19 | user: Awaited>; 20 | }>()({ 21 | beforeLoad: async ({ context }) => { 22 | const user = await context.queryClient.fetchQuery({ 23 | queryKey: ["user"], 24 | queryFn: ({ signal }) => getUser({ signal }), 25 | }); // we're using react-query for caching, see router.tsx 26 | return { user }; 27 | }, 28 | head: () => ({ 29 | meta: [ 30 | { 31 | charSet: "utf-8", 32 | }, 33 | { 34 | name: "viewport", 35 | content: "width=device-width, initial-scale=1", 36 | }, 37 | { 38 | title: "React TanStarter", 39 | }, 40 | { 41 | name: "description", 42 | content: "A minimal starter template for 🏝️ TanStack Start.", 43 | }, 44 | ], 45 | links: [{ rel: "stylesheet", href: appCss }], 46 | }), 47 | component: RootComponent, 48 | }); 49 | 50 | function RootComponent() { 51 | return ( 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | function RootDocument({ children }: { readonly children: React.ReactNode }) { 59 | return ( 60 | // suppress since we're updating the "dark" class in a custom script below 61 | 62 | 63 | 64 | 65 | 66 | 67 | {`document.documentElement.classList.toggle( 68 | 'dark', 69 | localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) 70 | )`} 71 | 72 | 73 | {children} 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/routes/api/auth/$.ts: -------------------------------------------------------------------------------- 1 | import { createServerFileRoute } from "@tanstack/react-start/server"; 2 | import { auth } from "~/lib/auth"; 3 | 4 | export const ServerRoute = createServerFileRoute("/api/auth/$").methods({ 5 | GET: ({ request }) => { 6 | return auth.handler(request); 7 | }, 8 | POST: ({ request }) => { 9 | return auth.handler(request); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/routes/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | 3 | export const Route = createFileRoute("/dashboard/")({ 4 | component: DashboardIndex, 5 | }); 6 | 7 | function DashboardIndex() { 8 | return ( 9 |
10 | Dashboard index page 11 |
12 |         routes/dashboard/index.tsx
13 |       
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/dashboard/route.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, Link, Outlet, redirect } from "@tanstack/react-router"; 2 | import { Button } from "~/components/ui/button"; 3 | 4 | export const Route = createFileRoute("/dashboard")({ 5 | component: DashboardLayout, 6 | beforeLoad: async ({ context }) => { 7 | if (!context.user) { 8 | throw redirect({ to: "/login" }); 9 | } 10 | 11 | // `context.queryClient` is also available in our loaders 12 | // https://tanstack.com/start/latest/docs/framework/react/examples/start-basic-react-query 13 | // https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading 14 | }, 15 | }); 16 | 17 | function DashboardLayout() { 18 | return ( 19 |
20 |
21 |

Dashboard Layout

22 |
23 | This is a protected layout: 24 |
25 |             routes/dashboard/route.tsx
26 |           
27 |
28 | 29 | 32 |
33 | 34 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "@tanstack/react-query"; 2 | import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; 3 | import ThemeToggle from "~/components/ThemeToggle"; 4 | import { Button } from "~/components/ui/button"; 5 | import authClient from "~/lib/auth/auth-client"; 6 | 7 | export const Route = createFileRoute("/")({ 8 | component: Home, 9 | loader: ({ context }) => { 10 | return { user: context.user }; 11 | }, 12 | }); 13 | 14 | function Home() { 15 | const { user } = Route.useLoaderData(); 16 | const queryClient = useQueryClient(); 17 | const router = useRouter(); 18 | 19 | return ( 20 |
21 |
22 |

React TanStarter

23 |
24 | This is an unprotected page: 25 |
26 |             routes/index.tsx
27 |           
28 |
29 |
30 | 31 | {user ? ( 32 |
33 |

Welcome back, {user.name}!

34 | 37 |
38 | Session user: 39 |
40 |               {JSON.stringify(user, null, 2)}
41 |             
42 |
43 | 44 | 57 |
58 | ) : ( 59 |
60 |

You are not signed in.

61 | 64 |
65 | )} 66 | 67 | 78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" source("./"); 2 | 3 | @import "tw-animate-css"; 4 | 5 | @custom-variant dark (&:is(.dark *)); 6 | 7 | @theme inline { 8 | --radius-sm: calc(var(--radius) - 4px); 9 | --radius-md: calc(var(--radius) - 2px); 10 | --radius-lg: var(--radius); 11 | --radius-xl: calc(var(--radius) + 4px); 12 | --color-background: var(--background); 13 | --color-foreground: var(--foreground); 14 | --color-card: var(--card); 15 | --color-card-foreground: var(--card-foreground); 16 | --color-popover: var(--popover); 17 | --color-popover-foreground: var(--popover-foreground); 18 | --color-primary: var(--primary); 19 | --color-primary-foreground: var(--primary-foreground); 20 | --color-secondary: var(--secondary); 21 | --color-secondary-foreground: var(--secondary-foreground); 22 | --color-muted: var(--muted); 23 | --color-muted-foreground: var(--muted-foreground); 24 | --color-accent: var(--accent); 25 | --color-accent-foreground: var(--accent-foreground); 26 | --color-destructive: var(--destructive); 27 | --color-border: var(--border); 28 | --color-input: var(--input); 29 | --color-ring: var(--ring); 30 | --color-chart-1: var(--chart-1); 31 | --color-chart-2: var(--chart-2); 32 | --color-chart-3: var(--chart-3); 33 | --color-chart-4: var(--chart-4); 34 | --color-chart-5: var(--chart-5); 35 | --color-sidebar: var(--sidebar); 36 | --color-sidebar-foreground: var(--sidebar-foreground); 37 | --color-sidebar-primary: var(--sidebar-primary); 38 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 39 | --color-sidebar-accent: var(--sidebar-accent); 40 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 41 | --color-sidebar-border: var(--sidebar-border); 42 | --color-sidebar-ring: var(--sidebar-ring); 43 | } 44 | 45 | :root { 46 | --radius: 0.625rem; 47 | --background: oklch(1 0 0); 48 | --foreground: oklch(0.141 0.005 285.823); 49 | --card: oklch(1 0 0); 50 | --card-foreground: oklch(0.141 0.005 285.823); 51 | --popover: oklch(1 0 0); 52 | --popover-foreground: oklch(0.141 0.005 285.823); 53 | --primary: oklch(0.21 0.006 285.885); 54 | --primary-foreground: oklch(0.985 0 0); 55 | --secondary: oklch(0.967 0.001 286.375); 56 | --secondary-foreground: oklch(0.21 0.006 285.885); 57 | --muted: oklch(0.967 0.001 286.375); 58 | --muted-foreground: oklch(0.552 0.016 285.938); 59 | --accent: oklch(0.967 0.001 286.375); 60 | --accent-foreground: oklch(0.21 0.006 285.885); 61 | --destructive: oklch(0.577 0.245 27.325); 62 | --border: oklch(0.92 0.004 286.32); 63 | --input: oklch(0.92 0.004 286.32); 64 | --ring: oklch(0.705 0.015 286.067); 65 | --chart-1: oklch(0.646 0.222 41.116); 66 | --chart-2: oklch(0.6 0.118 184.704); 67 | --chart-3: oklch(0.398 0.07 227.392); 68 | --chart-4: oklch(0.828 0.189 84.429); 69 | --chart-5: oklch(0.769 0.188 70.08); 70 | --sidebar: oklch(0.985 0 0); 71 | --sidebar-foreground: oklch(0.141 0.005 285.823); 72 | --sidebar-primary: oklch(0.21 0.006 285.885); 73 | --sidebar-primary-foreground: oklch(0.985 0 0); 74 | --sidebar-accent: oklch(0.967 0.001 286.375); 75 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885); 76 | --sidebar-border: oklch(0.92 0.004 286.32); 77 | --sidebar-ring: oklch(0.705 0.015 286.067); 78 | } 79 | 80 | .dark { 81 | --background: oklch(0.141 0.005 285.823); 82 | --foreground: oklch(0.985 0 0); 83 | --card: oklch(0.21 0.006 285.885); 84 | --card-foreground: oklch(0.985 0 0); 85 | --popover: oklch(0.21 0.006 285.885); 86 | --popover-foreground: oklch(0.985 0 0); 87 | --primary: oklch(0.92 0.004 286.32); 88 | --primary-foreground: oklch(0.21 0.006 285.885); 89 | --secondary: oklch(0.274 0.006 286.033); 90 | --secondary-foreground: oklch(0.985 0 0); 91 | --muted: oklch(0.274 0.006 286.033); 92 | --muted-foreground: oklch(0.705 0.015 286.067); 93 | --accent: oklch(0.274 0.006 286.033); 94 | --accent-foreground: oklch(0.985 0 0); 95 | --destructive: oklch(0.704 0.191 22.216); 96 | --border: oklch(1 0 0 / 10%); 97 | --input: oklch(1 0 0 / 15%); 98 | --ring: oklch(0.552 0.016 285.938); 99 | --chart-1: oklch(0.488 0.243 264.376); 100 | --chart-2: oklch(0.696 0.17 162.48); 101 | --chart-3: oklch(0.769 0.188 70.08); 102 | --chart-4: oklch(0.627 0.265 303.9); 103 | --chart-5: oklch(0.645 0.246 16.439); 104 | --sidebar: oklch(0.21 0.006 285.885); 105 | --sidebar-foreground: oklch(0.985 0 0); 106 | --sidebar-primary: oklch(0.488 0.243 264.376); 107 | --sidebar-primary-foreground: oklch(0.985 0 0); 108 | --sidebar-accent: oklch(0.274 0.006 286.033); 109 | --sidebar-accent-foreground: oklch(0.985 0 0); 110 | --sidebar-border: oklch(1 0 0 / 10%); 111 | --sidebar-ring: oklch(0.552 0.016 285.938); 112 | } 113 | 114 | @layer base { 115 | * { 116 | @apply border-border outline-ring/50; 117 | } 118 | body { 119 | @apply bg-background text-foreground; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 10 | "isolatedModules": true, 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "target": "ES2022", 14 | "allowJs": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./src/*"] 19 | }, 20 | "noEmit": true, 21 | "strictNullChecks": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from "@tailwindcss/vite"; 2 | import { tanstackStart } from "@tanstack/react-start/plugin/vite"; 3 | import { defineConfig } from "vite"; 4 | import tsConfigPaths from "vite-tsconfig-paths"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | tsConfigPaths({ 9 | projects: ["./tsconfig.json"], 10 | }), 11 | tailwindcss(), 12 | tanstackStart({ 13 | // https://react.dev/learn/react-compiler 14 | react: { 15 | babel: { 16 | plugins: [ 17 | [ 18 | "babel-plugin-react-compiler", 19 | { 20 | target: "19", 21 | }, 22 | ], 23 | ], 24 | }, 25 | }, 26 | 27 | tsr: { 28 | quoteStyle: "double", 29 | semicolons: true, 30 | // verboseFileRoutes: false, 31 | }, 32 | 33 | // https://tanstack.com/start/latest/docs/framework/react/hosting#deployment 34 | // target: "node-server", 35 | }), 36 | ], 37 | }); 38 | --------------------------------------------------------------------------------