diff --git a/.agents/auth.md b/.agents/auth.md new file mode 100644 index 0000000..75a3f76 --- /dev/null +++ b/.agents/auth.md @@ -0,0 +1,26 @@ +# Auth Conventions + +## Auth Architecture + +- Better Auth config lives in `packages/auth/src/auth.ts`. +- Auth utilities are centralized in `packages/auth/src/tanstack/*`. +- In components, prefer shared auth hooks (`useAuth`, `useAuthSuspense`) from `packages/auth/src/tanstack/hooks.ts`. These reuse the same auth data as the route loader. +- For route loaders under `_auth`, prefer loader context user over duplicate auth fetches. + +## Route Guards + +- Protected route layout is `apps/web/src/routes/_auth/route.tsx`. + - It enforces auth in `beforeLoad` using `ensureQueryData(authQueryOptions())`. + - It returns `{ user }`, which is available to all child route loaders via router context. +- Guest-only route layout is `apps/web/src/routes/_guest/route.tsx`. + - It redirects authenticated users away from login/signup routes. + +## Server Functions and Mutations + +- Server functions can be called from both server and client code. + - Server call: executed directly on the server. + - Client call: treated as RPC and executed through an HTTP API request. +- Treat protected server functions like protected API routes from a security perspective. +- If a server function requires auth, always apply `authMiddleware` from `packages/auth/src/tanstack/middleware.ts`. This applies even when called from an auth-protected route (`routes/_auth/**`). +- Route-level `beforeLoad` guards protect route navigation/rendering, but they do not replace server-function authorization. +- When auth is required, middleware-provided user context is the source of truth. diff --git a/.agents/tanstack-patterns.md b/.agents/tanstack-patterns.md new file mode 100644 index 0000000..fee2c18 --- /dev/null +++ b/.agents/tanstack-patterns.md @@ -0,0 +1,86 @@ + + +# TanStack Patterns + +## Route Group Conventions + +- Protected routes live under `apps/web/src/routes/_auth/**`, enforced by `beforeLoad` in the `_auth` layout (`apps/web/src/routes/_auth/route.tsx`). +- Guest-only routes live under `apps/web/src/routes/_guest/**`, enforced by `beforeLoad` in the `_guest` layout (`apps/web/src/routes/_guest/route.tsx`). +- Auth-specific route guard behavior and middleware rules are documented in `.agents/auth.md`. + +## Data Fetching + +Route loaders are isomorphic; they run on both server and client. They cannot directly access server-only APIs. + +```typescript +// Bad: direct server API access +loader: async () => { + const todos = await fs.readFile("todos.json"); + return { todos }; +}; +``` + +```typescript +// Good (minimal/valid): call a server function from the loader +loader: async () => { + const todos = await $getTodos({ data: {} }); + return { todos }; +}; +``` + +Instead of directly calling server functions in loaders, prefer wrapping in TanStack Query for better caching and reusability. + +```typescript +loader: async ({ context }) => { + // Best/Preferred: For read/data-fetching server functions, wrap in TanStack Query + const todos = await context.queryClient.ensureQueryData(todosQueryOptions()); + return { todos }; +}; + +// lib/todos/queries.ts +export const todosQueryOptions = () => + queryOptions({ + queryKey: ["todos"], + queryFn: ({ signal }) => $getTodos({ signal }), // TanStack Query calls the server function + }); +``` + +## Environment Shaking + +TanStack Start strips any code not referenced by a `createServerFn` handler from the client build. + +- Server-only code (database, fs) is automatically excluded from client bundles +- Only code inside `createServerFn` handlers goes to server bundles +- Code outside handlers is included in both bundles + +## Importing Server Functions + +Server functions wrapped in `createServerFn` can be imported statically. Never use dynamic imports for server-only code in components. Prefix server function names with `$` (e.g. `$getUser`) for easier identification. + +```typescript +// Bad: dynamic import causes bundler issues +const rolesQuery = useQuery({ + queryFn: async () => { + const { $listRoles } = await import("~/utils/roles.server"); + return $listRoles({ data: {} }); + }, +}); + +// Good: static import +import { $listRoles } from "~/utils/roles.server"; + +const rolesQuery = useQuery({ + queryFn: async () => $listRoles({ data: {} }), +}); +``` + +## Server-Only Import Rules + +1. `createServerFn` wrappers can be imported statically anywhere +2. Direct server-only code (database clients, fs) must only be imported: + - Inside `createServerFn` handlers + - In `*.server.ts` files + +## Auth-specific Patterns + +- See `.agents/auth.md` for auth middleware usage, route guards, and session/cookie patterns. diff --git a/.agents/typescript.md b/.agents/typescript.md new file mode 100644 index 0000000..c845615 --- /dev/null +++ b/.agents/typescript.md @@ -0,0 +1,47 @@ + + +# TypeScript Conventions + +## Avoid Type Casting + +Never cast types unless absolutely necessary. This includes: + +- Manual generic type parameters (e.g., ``) +- Type assertions using `as` +- Type assertions using `satisfies` + +## Prefer Type Inference + +Infer types by going up the logical chain: + +1. **Schema validation** as source of truth (e.g. Zod) +2. **Type inference** from function return types, API responses +3. **Fix at source** (schema, API definition, function signature) rather than casting at point of use + +```typescript +// Bad +const result = api.getData() as MyType; +const value = getValue(); + +// Good +const result = api.getData(); // Type inferred from return type +const value = getValue(); // Type inferred from implementation +``` + +## Generic Type Parameter Naming + +All generic type parameters must be prefixed with `T`. + +```typescript +// Bad +function withCapability( + handler: (user: AuthUser, ...args: Args) => R, +) { ... } + +// Good +function withCapability( + handler: (user: AuthUser, ...args: TArgs) => TReturn, +) { ... } +``` + +Common names: `T`, `TArgs`, `TReturn`, `TData`, `TError`, `TKey`, `TValue` diff --git a/.agents/workflow.md b/.agents/workflow.md new file mode 100644 index 0000000..30dc7ce --- /dev/null +++ b/.agents/workflow.md @@ -0,0 +1,18 @@ +# Workflow + +## Build Commands + +- `pnpm build`: Only for build/bundler issues or verifying production output +- `pnpm lint`: Type-checking & type-aware linting +- `pnpm dev` runs indefinitely in watch mode +- `pnpm db` for Drizzle Kit commands (e.g. `pnpm db generate` to generate a migration) + +Don't build after every change. If lint passes; assume changes work. + +## Testing + +No testing framework is currently set up. Prefer lint checks for now. + +## Formatting + +Oxfmt is configured for consistent code formatting via `pnpm format`. It runs automatically on commit via Husky pre-commit hooks, so manual formatting is not necessary. diff --git a/.editorconfig b/.editorconfig index de6051a..2b4ac33 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,4 +7,4 @@ indent_style = space indent_size = 2 charset = utf-8 trim_trailing_whitespace = true -max_line_length = 90 \ No newline at end of file +max_line_length = 100 \ No newline at end of file diff --git a/.gitignore b/.gitignore index e9b4731..e9aac7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,39 @@ -node_modules +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Ignore lockfiles we don't use -# package-lock.json -# yarn.lock -# pnpm-lock.yaml -# bun.lock +# Dependencies +node_modules +.pnp +.pnp.js -.DS_Store -.cache +# Local env files .env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo -.data +# Build Outputs +out/ +build +dist +.nitro .vercel -.output .wrangler -.netlify -dist -/build/ -/api/ -/server/build -/public/build -.tanstack \ No newline at end of file +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem +.cache +.tanstack +worker-configuration.d.ts \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..dff836d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged \ No newline at end of file diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..d710725 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,36 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "tabWidth": 2, + "semi": true, + "printWidth": 100, + "singleQuote": false, + "endOfLine": "lf", + "trailingComma": "all", + "experimentalSortImports": {}, + "experimentalTailwindcss": { + "stylesheet": "./packages/ui/styles/base.css", + "attributes": ["class", "className"], + "functions": ["clsx", "cn", "cva", "tw"] + }, + "ignorePatterns": [ + "pnpm-lock.yaml", + "package-lock.json", + "yarn.lock", + "bun.lock", + "pnpm-workspace.yaml", + "routeTree.gen.ts", + ".tanstack-start/", + ".tanstack/", + "drizzle/", + "migrations/", + ".drizzle/", + ".turbo", + ".cache", + "worker-configuration.d.ts", + ".vercel", + ".output", + ".wrangler", + ".netlify", + "dist" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..dd05929 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,48 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "react", "react-perf", "jsx-a11y"], + "env": { + "builtin": true, + "node": true, + "browser": true + }, + "jsPlugins": [ + "eslint-plugin-turbo", + // Plugins with "/" in name have to be aliased for now + // Issue: https://github.com/oxc-project/oxc/issues/14557 + { + "name": "eslint-tanstack-router", + "specifier": "@tanstack/eslint-plugin-router" + }, + { + "name": "eslint-tanstack-query", + "specifier": "@tanstack/eslint-plugin-query" + } + ], + "rules": { + "no-deprecated": "warn", + "typescript/no-floating-promises": "off", + "typescript/no-misused-spread": "off", + + "turbo/no-undeclared-env-vars": "warn", + + "eslint-tanstack-router/create-route-property-order": "warn", + "eslint-tanstack-query/exhaustive-deps": "warn", + "eslint-tanstack-query/stable-query-client": "warn", + "eslint-tanstack-query/no-rest-destructuring": "warn", + "eslint-tanstack-query/no-unstable-deps": "warn", + "eslint-tanstack-query/infinite-query-property-order": "warn", + "eslint-tanstack-query/no-void-query-fn": "warn", + "eslint-tanstack-query/mutation-property-order": "warn" + }, + "ignorePatterns": [ + "dist", + ".wrangler", + ".vercel", + ".netlify", + ".output", + "build/", + "worker-configuration.d.ts", + "scripts/" + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 109a1bd..0000000 --- a/.prettierignore +++ /dev/null @@ -1,18 +0,0 @@ -# lockfiles -pnpm-lock.yaml -package-lock.json -yarn.lock -bun.lock - -# misc -routeTree.gen.ts -.tanstack/ -drizzle/ -.drizzle/ - -# build outputs -.vercel -.output -.wrangler -.netlify -dist \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 9da68af..0000000 --- a/.prettierrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "tabWidth": 2, - "semi": true, - "printWidth": 90, - "singleQuote": false, - "endOfLine": "lf", - "trailingComma": "all", - "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"] -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 13d4039..4ea727f 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,3 @@ { - "recommendations": [ - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint", - "bradlc.vscode-tailwindcss" - ] + "recommendations": ["oxc.oxc-vscode", "bradlc.vscode-tailwindcss"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 10f4f31..c36317c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,37 +2,38 @@ "files.readonlyInclude": { "**/routeTree.gen.ts": true, "**/.tanstack/**/*": true, + "**/worker-configuration.d.ts": true, "pnpm-lock.yaml": true, "bun.lock": true }, "files.watcherExclude": { "**/routeTree.gen.ts": true, "**/.tanstack/**/*": true, + "**/worker-configuration.d.ts": true, "pnpm-lock.yaml": true, "bun.lock": true }, "search.exclude": { "**/routeTree.gen.ts": true, "**/.tanstack/**/*": true, + "**/worker-configuration.d.ts": true, "pnpm-lock.yaml": true, "bun.lock": true }, - "explorer.fileNesting.enabled": true, - "explorer.fileNesting.patterns": { - "tsconfig.json": "tsconfig.*.json, env.d.ts", - "vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*", - "package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .prettier*, prettier*, .editorconfig, .gitattributes, bun.lock" - }, - - // always choose typescript from node_modules - "typescript.tsdk": "./node_modules/typescript/lib", - // use LF line endings "files.eol": "\n", - // set prettier as default formatter for json, ts, tsx, js, jsx, html, css + // disable prettier & eslint for oxc + oxfmt + "prettier.enable": false, + "eslint.enable": false, + "oxc.enable": true, + + // type-aware linting + "oxc.typeAware": true, + + // set oxfmt as default formatter for json, ts, tsx, js, jsx, html, css "[json][jsonc][typescript][typescriptreact][javascript][javascriptreact][html][css]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" } } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..354613b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agent Guidelines + +## Essentials + +- Stack: TypeScript + React (TanStack Start) in a pnpm + Turborepo monorepo, with Drizzle ORM, shadcn/ui, and Better Auth. +- Prefer shared `@repo/ui` components; add primitives via shadcn CLI (`pnpm ui add `). +- Use shared pnpm catalog versions (`pnpm-workspace.yaml`) via `catalog:`. +- Don't build after every little change. If `pnpm lint` passes; assume changes work. + +## Topic-specific Guidelines + +- [TanStack patterns](.agents/tanstack-patterns.md) - Routing, data fetching, loaders, server functions, environment shaking +- [Auth patterns](.agents/auth.md) - Route guards, middleware, auth utilities +- [TypeScript conventions](.agents/typescript.md) - Casting rules, prefer type inference +- [Workflow](.agents/workflow.md) - Workflow commands, validation approach diff --git a/LICENSE b/LICENSE index fdddb29..c16d74b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,21 @@ -This is free and unencumbered software released into the public domain. +MIT License -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Copyright (c) 2025-present Nathaniel John Tampus -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -For more information, please refer to +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f4e8bb4..30f558d 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,88 @@ -# [React TanStarter](https://github.com/dotnize/react-tanstarter) +# TanStarter -> [!TIP] -> We're working on a monorepo version of this template with Turborepo and Cloudflare. See the [`next` branch](https://github.com/dotnize/react-tanstarter/tree/next) if you want to try it out. ([#45](https://github.com/dotnize/react-tanstarter/pull/45)) +> [!WARNING] +> This is still a work in progress. Mainly blocked by: +> +> - [ ] Drizzle Relations v2 support in Better Auth (https://github.com/better-auth/better-auth/pull/6913) +> +> Also see the [issue watchlist](#issue-watchlist) below. -A minimal starter template for 🏝️ TanStack Start. [β†’ Preview here](https://tanstarter.nize.ph/) +A minimal monorepo starter for 🏝️ TanStack Start. +- [Turborepo](https://turborepo.com/) + [pnpm](https://pnpm.io/) - [React 19](https://react.dev) + [React Compiler](https://react.dev/learn/react-compiler) -- TanStack [Start](https://tanstack.com/start/latest) + [Router](https://tanstack.com/router/latest) + [Query](https://tanstack.com/query/latest) -- [Tailwind CSS](https://tailwindcss.com/) + [shadcn/ui](https://ui.shadcn.com/) + [Base UI](https://base-ui.com/) -- [Vite 8](https://vite.dev/blog/announcing-vite8-beta) (beta) + [Nitro v3](https://v3.nitro.build/) (nightly) +- TanStack [Start](https://tanstack.com/start/latest) + [Router](https://tanstack.com/router/latest) + [Query](https://tanstack.com/query/latest) + [Form](https://tanstack.com/form/latest) +- [Vite 8](https://vite.dev/) + [Nitro v3](https://v3.nitro.build/) +- [Tailwind CSS v4](https://tailwindcss.com/) + [shadcn/ui](https://ui.shadcn.com/) + [Base UI](https://base-ui.com/) (base-maia) - [Drizzle ORM](https://orm.drizzle.team/) + PostgreSQL - [Better Auth](https://www.better-auth.com/) +- [Oxlint](https://oxc.rs/docs/guide/usage/linter.html) + [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) with [Husky](https://typicode.github.io/husky/) + [lint-staged](https://github.com/lint-staged/lint-staged) + +```sh +β”œβ”€β”€ apps +β”‚ β”œβ”€β”€ web # TanStack Start web app +β”œβ”€β”€ packages +β”‚ β”œβ”€β”€ auth # Better Auth +β”‚ β”œβ”€β”€ db # Drizzle ORM + Drizzle Kit + PostgreSQL +β”‚ └── ui # shadcn/ui primitives & utils +β”œβ”€β”€ tooling +β”‚ └── tsconfig # Shared TypeScript configuration +β”œβ”€β”€ turbo.json +β”œβ”€β”€ LICENSE +└── README.md +``` + +## Table of Contents + +- [Getting Started](#getting-started) +- [Deploying to production](#deploying-to-production) +- [Issue watchlist](#issue-watchlist) +- [Goodies](#goodies) + - [Scripts](#scripts) + - [Utilities](#utilities) +- [Third-party integrations](#thirdparty-integrations) +- [Ecosystem](#ecosystem) ## Getting Started -1. [Use this template](https://github.com/new?template_name=react-tanstarter&template_owner=dotnize) or clone this repository with gitpick: - - ```bash - npx gitpick dotnize/react-tanstarter myapp - cd myapp - ``` +1. Clone this repository with gitpick, then install dependencies: -2. Install dependencies: + ```sh + npx gitpick dotnize/react-tanstarter/tree/next myproject + cd myproject - ```bash pnpm install ``` -3. Create a `.env` file based on [`.env.example`](./.env.example). +2. Create `.env` files in [`/apps/web`](./apps/web/.env.example) and [`/packages/db`](./packages/db/.env.example) based on their respective `.env.example` files. -4. Push the schema to your database with drizzle-kit: +3. Generate the initial migration with drizzle-kit, then apply to your database: - ```bash - pnpm db push + ```sh + pnpm db generate + pnpm db migrate ``` - https://orm.drizzle.team/docs/migrations +4. Run the development server: -5. Run the development server: - - ```bash + ```sh pnpm dev ``` The development server should now be running at [http://localhost:3000](http://localhost:3000). +> [!TIP] +> If you want to run a local Postgres instance via Docker Compose with the dev server, you can use the [dev.sh](./dev.sh) script: +> +> ```sh +> ./dev.sh # runs "pnpm dev" +> # or +> ./dev.sh web # runs pnpm dev:web +> ``` + ## Deploying to production -The [vite config](./vite.config.ts#L12-L13) is currently configured to use [Nitro v3](https://v3.nitro.build) (nightly) to deploy on Vercel, but can be easily switched to other providers. +The [vite config](./apps/web/vite.config.ts#L15-L16) is currently configured to use Nitro v3 to deploy on Vercel, but supports many other [deployment presets](https://v3.nitro.build/deploy) like Node. Refer to the [TanStack Start hosting docs](https://tanstack.com/start/latest/docs/framework/react/guide/hosting) for deploying to other platforms. @@ -56,31 +91,44 @@ Refer to the [TanStack Start hosting docs](https://tanstack.com/start/latest/doc - [Router/Start issues](https://github.com/TanStack/router/issues) - TanStack Start is in RC. - [Devtools releases](https://github.com/TanStack/devtools/releases) - TanStack Devtools is in alpha and may still have breaking changes. - [Vite 8 beta](https://vite.dev/blog/announcing-vite8-beta) - We're using Vite 8 beta which is powered by Rolldown. -- [Nitro v3 nightly](https://v3.nitro.build/docs/nightly) - The template is configured with Nitro v3 nightly by default. +- [Nitro v3 nightly](https://v3.nitro.build/docs/nightly) - This template is configured with Nitro v3 nightly by default. +- [Drizzle ORM v1 Beta](https://orm.drizzle.team/docs/relations-v1-v2) - Drizzle ORM v1 is in beta with relations v2. +- [Better Auth beta](https://github.com/better-auth/better-auth/pull/6913) - We're using a separate branch of Better Auth v1.5 that supports Drizzle relations v2. ## Goodies #### Scripts -We use **pnpm** by default, but you can modify these scripts in [package.json](./package.json) to use your preferred package manager. +This template is configured for **[pnpm](https://pnpm.io/)** by default. Check the root [package.json](./package.json) and each workspace package's `package.json` for the full list of available scripts. -- **`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/auth.ts). -- **`db`** - Run [drizzle-kit](https://orm.drizzle.team/docs/kit-overview) commands. (e.g. `pnpm db generate`, `pnpm db studio`) +- **`auth:generate`** - Regenerate the [auth db schema](./packages/db/src/schema/auth.schema.ts) if you've made changes to your Better Auth [config](./packages/auth/src/auth.ts). - **`ui`** - The shadcn/ui CLI. (e.g. `pnpm ui add button`) -- **`format`**, **`lint`**, **`check-types`** - Run Prettier, ESLint, and check TypeScript types respectively. - - **`check`** - Run all three above. (e.g. `pnpm check`) +- **`format`**, **`lint`** - Run Oxfmt and Oxlint, or both via `pnpm check`. - **`deps`** - Selectively upgrade dependencies via taze. +> [!NOTE] +> To switch to another package manager (e.g., bun or npm), update the commands in your `package.json` files, [`dev.sh`](./dev.sh), and [`.husky/pre-commit`](./.husky/pre-commit). You'll also need to replace or remove [`pnpm-workspace.yaml`](./pnpm-workspace.yaml), which uses pnpm [catalogs](https://pnpm.io/catalogs). Bun and Yarn have their own equivalents, but the file formats may differ. + #### Utilities -- [`auth/middleware.ts`](./src/lib/auth/middleware.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)) -- [`theme-toggle.tsx`](./src/components/theme-toggle.tsx), [`theme-provider.tsx`](./src/components/theme-provider.tsx) - A theme toggle and provider for toggling between light and dark mode. ([#7](https://github.com/dotnize/react-tanstarter/issues/7#issuecomment-3141530412)) +- [`/auth/src/tanstack/middleware.ts`](./packages/auth/src/tanstack/middleware.ts) - Sample middleware for forcing authentication on server functions. +- [`/web/src/components/theme-toggle.tsx`](./apps/web/src/components/theme-toggle.tsx), [`/ui/lib/theme-provider.tsx`](./packages/ui/lib/theme-provider.tsx) - A theme toggle and provider for toggling between light and dark mode. + +## Third‑party integrations + +The template is kept minimal by default, but is compatible with many third‑party integrations. Here are a few we use in our projects: + +- [PostHog](https://posthog.com/) - analytics & observability +- [Resend](https://resend.com/) - email +- [Polar](https://polar.sh/) - billing +- ... and many more! ## License -Code in this template is public domain via [Unlicense](./LICENSE). Feel free to remove or replace for your own project. +[MIT](./LICENSE) -## Also check out +## Ecosystem -- [@tanstack/create-start](https://github.com/TanStack/create-tsrouter-app/blob/main/cli/ts-create-start/README.md) - The official CLI tool from the TanStack team to create Start projects. +- [TanStack MCP](https://tanstack.com/cli/latest/docs/mcp/connecting) - The official MCP server for searching the latest docs for TanStack libraries. - [awesome-tanstack-start](https://github.com/Balastrong/awesome-tanstack-start) - A curated list of awesome resources for TanStack Start. +- [shadcn/ui Directory](https://ui.shadcn.com/docs/directory), [MCP](https://ui.shadcn.com/docs/mcp), [shoogle.dev](https://shoogle.dev/) - Component directories & registries for shadcn/ui. diff --git a/.env.example b/apps/web/.env.example similarity index 60% rename from .env.example rename to apps/web/.env.example index cc0b24a..dd6de93 100644 --- a/.env.example +++ b/apps/web/.env.example @@ -1,17 +1,17 @@ VITE_BASE_URL=http://localhost:3000 -DATABASE_URL="postgresql://user:password@localhost:5432/tanstarter" +SERVER_DATABASE_URL="postgresql://postgres:password@localhost:5432/tanstarter" # You can also use Docker Compose to set up a local PostgreSQL database: # docker-compose up -d -# pnpm run auth:secret -BETTER_AUTH_SECRET= +# pnpm auth:secret +SERVER_AUTH_SECRET= # OAuth2 providers, optional, update as needed -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= +SERVER_GITHUB_CLIENT_ID= +SERVER_GITHUB_CLIENT_SECRET= +SERVER_GOOGLE_CLIENT_ID= +SERVER_GOOGLE_CLIENT_SECRET= # NOTE: # In your OAuth2 apps, set callback/redirect URIs to`http://localhost:3000/api/auth/callback/` diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..4f4baad --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,22 @@ +node_modules + +# Ignore lockfiles we don't use +# package-lock.json +# yarn.lock +# pnpm-lock.yaml +# bun.lock + +.DS_Store +.cache +.env + +.data +.vercel +.output +.wrangler +.netlify +dist +/build/ +/api/ +/server/build +/public/build diff --git a/components.json b/apps/web/components.json similarity index 56% rename from components.json rename to apps/web/components.json index 6078525..93c1b5e 100644 --- a/components.json +++ b/apps/web/components.json @@ -1,24 +1,25 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-vega", + "style": "base-maia", "rsc": false, "tsx": true, "tailwind": { "config": "", - "css": "src/styles.css", + "css": "../../packages/ui/styles/base.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, - "iconLibrary": "lucide", + "iconLibrary": "remixicon", "aliases": { "components": "~/components", - "utils": "~/lib/utils", - "ui": "~/components/ui", - "lib": "~/lib", - "hooks": "~/hooks" + "utils": "@repo/ui/lib/utils", + "ui": "@repo/ui/components", + "lib": "@repo/ui/lib", + "hooks": "@repo/ui/hooks" }, "menuColor": "default", "menuAccent": "subtle", + "rtl": false, "registries": {} } diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..e55ad7e --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,50 @@ +{ + "name": "@repo/web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "start": "node .output/server/index.mjs", + "ui": "pnpm dlx shadcn@latest" + }, + "dependencies": { + "@fontsource-variable/inter": "catalog:", + "@remixicon/react": "catalog:", + "@repo/auth": "workspace:*", + "@repo/db": "workspace:*", + "@repo/ui": "workspace:*", + "@tanstack/react-form-start": "catalog:", + "@tanstack/react-query": "catalog:", + "@tanstack/react-router": "catalog:", + "@tanstack/react-router-ssr-query": "catalog:", + "@tanstack/react-start": "catalog:", + "date-fns": "catalog:", + "drizzle-orm": "catalog:", + "motion": "catalog:", + "nanoid": "catalog:", + "nitro": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@repo/tsconfig": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@tanstack/devtools-vite": "catalog:", + "@tanstack/react-devtools": "catalog:", + "@tanstack/react-form-devtools": "catalog:", + "@tanstack/react-query-devtools": "catalog:", + "@tanstack/react-router-devtools": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "babel-plugin-react-compiler": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000..1ac5b27 Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/src/components/default-catch-boundary.tsx b/apps/web/src/components/default-catch-boundary.tsx similarity index 90% rename from src/components/default-catch-boundary.tsx rename to apps/web/src/components/default-catch-boundary.tsx index 357b6cf..2b47025 100644 --- a/src/components/default-catch-boundary.tsx +++ b/apps/web/src/components/default-catch-boundary.tsx @@ -1,3 +1,4 @@ +import { Button } from "@repo/ui/components/button"; import { ErrorComponent, type ErrorComponentProps, @@ -6,7 +7,6 @@ import { useMatch, useRouter, } from "@tanstack/react-router"; -import { Button } from "./ui/button"; export function DefaultCatchBoundary({ error }: Readonly) { const router = useRouter(); @@ -23,8 +23,8 @@ export function DefaultCatchBoundary({ error }: Readonly) {
-
- - Or - +
+ Or
{ - const REDIRECT_URL = "/dashboard"; + const REDIRECT_URL = "/app"; const user = await context.queryClient.ensureQueryData({ ...authQueryOptions(), @@ -24,7 +24,7 @@ export const Route = createFileRoute("/_guest")({ function RouteComponent() { return ( -
+
diff --git a/src/routes/_guest/signup.tsx b/apps/web/src/routes/_guest/signup.tsx similarity index 85% rename from src/routes/_guest/signup.tsx rename to apps/web/src/routes/_guest/signup.tsx index d206da0..d8b87eb 100644 --- a/src/routes/_guest/signup.tsx +++ b/apps/web/src/routes/_guest/signup.tsx @@ -1,13 +1,14 @@ +import { RiGalleryView, RiLoader4Line } from "@remixicon/react"; +import authClient from "@repo/auth/auth-client"; +import { authQueryOptions } from "@repo/auth/tanstack/queries"; +import { Button } from "@repo/ui/components/button"; +import { Input } from "@repo/ui/components/input"; +import { Label } from "@repo/ui/components/label"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { GalleryVerticalEnd, LoaderCircle } from "lucide-react"; import { toast } from "sonner"; + import { SignInSocialButton } from "~/components/sign-in-social-button"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import authClient from "~/lib/auth/auth-client"; -import { authQueryOptions } from "~/lib/auth/queries"; export const Route = createFileRoute("/_guest/signup")({ component: SignupForm, @@ -29,9 +30,9 @@ function SignupForm() { onError: ({ error }) => { toast.error(error.message || "An error occurred while signing up."); }, - onSuccess: () => { + onSuccess: async () => { queryClient.removeQueries({ queryKey: authQueryOptions().queryKey }); - navigate({ to: redirectUrl }); + await navigate({ to: redirectUrl }); }, }, ); @@ -63,9 +64,9 @@ function SignupForm() {
- +
- +
Acme Inc.
@@ -117,14 +118,12 @@ function SignupForm() { />
-
- - Or - +
+ Or
-

React TanStarter

-
+

TanStarter

+
This is an unprotected page: -
-            routes/index.tsx
-          
+
routes/index.tsx
@@ -29,20 +27,20 @@ function HomePage() {

- A minimal starter template for{" "} + A monorepo template for{" "} 🏝️ TanStack Start - - . + {" "} + with Turborepo.

Welcome back, {user.name}!

-
Session user: @@ -84,12 +77,7 @@ function UserAction() { ) : (

You are not signed in.

-
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..6d1445b --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,7 @@ +@import "@repo/ui/styles/base.css"; + +@source "./"; + +@theme inline { + --font-sans: "Inter Variable", sans-serif; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..a2398ff --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@repo/tsconfig/base.json", + "compilerOptions": { + "paths": { + "~/*": ["./src/*"], + + // for shadcn CLI + "@repo/ui/*": ["../../packages/ui/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/vite.config.ts b/apps/web/vite.config.ts similarity index 95% rename from vite.config.ts rename to apps/web/vite.config.ts index 782c5c2..744e752 100644 --- a/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ resolve: { tsconfigPaths: true, }, + server: { + port: 3000, + }, plugins: [ devtools(), tanstackStart(), diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..97f3a2f --- /dev/null +++ b/dev.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +echo "Initiating dev setup with local PostgreSQL instance..." + +# Auto-detect compose command +if command -v podman-compose &> /dev/null; then + COMPOSE_CMD="podman-compose" +elif command -v docker-compose &> /dev/null; then + COMPOSE_CMD="docker-compose" +else + echo "Error: Neither podman-compose nor docker-compose found!" + exit 1 +fi + +echo "Using: $COMPOSE_CMD" + +# Start PostgreSQL +echo "Starting PostgreSQL..." +$COMPOSE_CMD up -d + +# Determine which dev command to run based on parameters +if [ -n "$1" ]; then + DEV_CMD="dev:$1" + echo "Starting $1 development server..." +else + DEV_CMD="dev" + echo "Starting all development servers..." +fi + +# Start the development server +pnpm $DEV_CMD + +# Cleanup function +cleanup() { + echo "Shutting down..." + $COMPOSE_CMD down + exit 0 +} + +# Trap cleanup function on script exit +trap cleanup SIGINT SIGTERM EXIT \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b159541..9b7fd38 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: volumes: - postgres_data_tanstarter:/var/lib/postgresql/data environment: - - POSTGRES_USER=user + - POSTGRES_USER=postgres - POSTGRES_PASSWORD=password - POSTGRES_DB=tanstarter diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 14fd0cc..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,34 +0,0 @@ -import react from "@eslint-react/eslint-plugin"; -import js from "@eslint/js"; -import pluginQuery from "@tanstack/eslint-plugin-query"; -import pluginRouter from "@tanstack/eslint-plugin-router"; -import eslintConfigPrettier from "eslint-config-prettier"; -import reactHooks from "eslint-plugin-react-hooks"; -import { defineConfig } from "eslint/config"; -import tseslint from "typescript-eslint"; - -export default defineConfig({ - files: ["**/*.{ts,tsx}"], - languageOptions: { - parser: tseslint.parser, - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - extends: [ - js.configs.recommended, - ...tseslint.configs.recommended, - eslintConfigPrettier, - ...pluginQuery.configs["flat/recommended"], - ...pluginRouter.configs["flat/recommended"], - reactHooks.configs.flat.recommended, - react.configs["recommended-type-checked"], - // ...you can add plugins or configs here - ], - rules: { - // You can override any rules here - "@typescript-eslint/no-deprecated": "warn", - }, - ignores: ["dist", ".wrangler", ".vercel", ".netlify", ".output", "build/"], -}); diff --git a/package.json b/package.json index d666737..020dfde 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,42 @@ { "name": "tanstarter", "private": true, - "type": "module", "scripts": { - "dev": "vite dev --port 3000", - "build": "vite build", - "preview": "vite preview", - "start": "node .output/server/index.mjs", - "lint": "eslint .", - "format": "prettier --write .", - "check-types": "tsc --noEmit", - "check": "pnpm format && pnpm lint && pnpm check-types", - "db": "drizzle-kit", - "deps": "pnpm dlx taze@latest -Ilw", - "deps:major": "pnpm dlx taze@latest major -Ilw", - "ui": "pnpm dlx shadcn@latest", + "build": "turbo run build", + "dev": "turbo run dev --parallel", + "dev:web": "turbo run dev --filter=@repo/web", + "lint": "oxlint --type-aware --type-check", + "lint:fix": "oxlint --type-aware --type-check --fix", + "format": "oxfmt", + "format:check": "oxfmt --check", + "check": "turbo run quality", + "prepare": "husky", + "deps": "pnpm dlx taze@latest -Ilwr", + "deps:major": "pnpm dlx taze@latest major -Ilwr", + "db": "pnpm --filter=@repo/db run db", + "ui": "pnpm --filter=@repo/ui run ui", + "ui:web": "pnpm --filter=@repo/web run ui", "auth:secret": "pnpm dlx @better-auth/cli@latest secret", - "auth:generate": "pnpm dlx @better-auth/cli@latest generate --config ./src/lib/auth/auth.ts --y --output ./src/lib/db/schema/auth.schema.ts && prettier --write ./src/lib/db/schema/auth.schema.ts" - }, - "dependencies": { - "@base-ui/react": "^1.1.0", - "@t3-oss/env-core": "^0.13.10", - "@tanstack/react-query": "^5.90.20", - "@tanstack/react-router": "^1.158.1", - "@tanstack/react-router-ssr-query": "^1.158.1", - "@tanstack/react-start": "^1.158.1", - "better-auth": "^1.4.18", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "drizzle-orm": "^0.45.1", - "lucide-react": "^0.563.0", - "nitro": "npm:nitro-nightly@latest", - "postgres": "^3.4.8", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "shadcn": "^3.8.3", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", - "zod": "^4.3.6" + "auth:generate": "pnpm --filter=@repo/db run auth:generate", + "skills": "pnpm dlx skills@latest" }, "devDependencies": { - "@eslint-react/eslint-plugin": "^2.9.4", - "@eslint/js": "^9.39.2", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/devtools-vite": "^0.5.0", - "@tanstack/eslint-plugin-query": "^5.91.4", - "@tanstack/eslint-plugin-router": "^1.155.0", - "@tanstack/react-devtools": "^0.9.4", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router-devtools": "^1.158.1", - "@types/node": "^24.10.10", - "@types/react": "^19.2.11", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", - "babel-plugin-react-compiler": "^1.0.0", - "drizzle-kit": "^0.31.8", - "eslint": "^9.39.2", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.0.1", - "prettier": "^3.8.1", - "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0", - "vite": "^8.0.0-beta.13" - } + "@tanstack/eslint-plugin-query": "catalog:", + "@tanstack/eslint-plugin-router": "catalog:", + "eslint-plugin-turbo": "catalog:", + "husky": "catalog:", + "lint-staged": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "tsx": "catalog:", + "turbo": "catalog:" + }, + "lint-staged": { + "*": "oxfmt --no-error-on-unmatched-pattern" + }, + "engines": { + "node": ">=24" + }, + "packageManager": "pnpm@10.30.1" } diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..8fbeeb2 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,23 @@ +{ + "name": "@repo/auth", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "dependencies": { + "@better-auth/drizzle-adapter": "catalog:", + "better-auth": "catalog:" + }, + "devDependencies": { + "@repo/tsconfig": "workspace:*", + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "@repo/db": "workspace:*", + "@tanstack/react-query": "catalog:", + "@tanstack/react-start": "catalog:" + } +} diff --git a/src/lib/auth/auth-client.ts b/packages/auth/src/auth-client.ts similarity index 65% rename from src/lib/auth/auth-client.ts rename to packages/auth/src/auth-client.ts index eefd5fa..30b3ef6 100644 --- a/src/lib/auth/auth-client.ts +++ b/packages/auth/src/auth-client.ts @@ -1,8 +1,7 @@ import { createAuthClient } from "better-auth/react"; -import { env } from "~/env/client"; const authClient = createAuthClient({ - baseURL: env.VITE_BASE_URL, + baseURL: process.env.VITE_BASE_URL, }); export default authClient; diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts new file mode 100644 index 0000000..cb2e065 --- /dev/null +++ b/packages/auth/src/auth.ts @@ -0,0 +1,51 @@ +import "@tanstack/react-start/server-only"; +import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; +import { db } from "@repo/db"; +import * as schema from "@repo/db/schema"; +import { betterAuth } from "better-auth/minimal"; +import { tanstackStartCookies } from "better-auth/tanstack-start"; + +export const auth = betterAuth({ + baseURL: process.env.VITE_BASE_URL, + secret: process.env.SERVER_AUTH_SECRET, + telemetry: { + enabled: false, + }, + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + + // https://www.better-auth.com/docs/integrations/tanstack#usage-tips + plugins: [tanstackStartCookies()], + + // https://www.better-auth.com/docs/concepts/session-management#session-caching + session: { + cookieCache: { + enabled: true, + maxAge: 5 * 60, // 5 minutes + }, + }, + + // https://www.better-auth.com/docs/concepts/oauth + socialProviders: { + github: { + clientId: process.env.SERVER_GITHUB_CLIENT_ID!, + clientSecret: process.env.SERVER_GITHUB_CLIENT_SECRET!, + }, + google: { + clientId: process.env.SERVER_GOOGLE_CLIENT_ID!, + clientSecret: process.env.SERVER_GOOGLE_CLIENT_SECRET!, + }, + }, + + // https://www.better-auth.com/docs/authentication/email-password + emailAndPassword: { + enabled: true, + }, + + experimental: { + // https://www.better-auth.com/docs/adapters/drizzle#joins-experimental + joins: true, + }, +}); diff --git a/src/lib/auth/functions.ts b/packages/auth/src/tanstack/functions.ts similarity index 93% rename from src/lib/auth/functions.ts rename to packages/auth/src/tanstack/functions.ts index 5373497..71e7455 100644 --- a/src/lib/auth/functions.ts +++ b/packages/auth/src/tanstack/functions.ts @@ -1,6 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequest, setResponseHeader } from "@tanstack/react-start/server"; -import { auth } from "~/lib/auth/auth"; + +import { auth } from "../auth"; export const $getUser = createServerFn({ method: "GET" }).handler(async () => { const session = await auth.api.getSession({ diff --git a/packages/auth/src/tanstack/hooks.ts b/packages/auth/src/tanstack/hooks.ts new file mode 100644 index 0000000..0d6933e --- /dev/null +++ b/packages/auth/src/tanstack/hooks.ts @@ -0,0 +1,17 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; + +import { authQueryOptions } from "./queries"; + +// These hooks can be used in route components or any components. +// They share the same deduped query as beforeLoad/loaders in __root and the _auth layout, +// so these will not result in unnecessary duplicate calls. + +export function useAuth() { + const { data: user, isPending } = useQuery(authQueryOptions()); + return { user, isPending }; +} + +export function useAuthSuspense() { + const { data: user } = useSuspenseQuery(authQueryOptions()); + return { user }; +} diff --git a/src/lib/auth/middleware.ts b/packages/auth/src/tanstack/middleware.ts similarity index 64% rename from src/lib/auth/middleware.ts rename to packages/auth/src/tanstack/middleware.ts index bcbfe76..df665c2 100644 --- a/src/lib/auth/middleware.ts +++ b/packages/auth/src/tanstack/middleware.ts @@ -1,16 +1,10 @@ import { createMiddleware } from "@tanstack/react-start"; -import { - getRequest, - setResponseHeader, - setResponseStatus, -} from "@tanstack/react-start/server"; -import { auth } from "~/lib/auth/auth"; +import { getRequest, setResponseHeader, setResponseStatus } from "@tanstack/react-start/server"; -// https://tanstack.com/start/latest/docs/framework/react/guide/middleware -// This is just an example middleware that you can modify and use in your server functions or routes. +import { auth } from "../auth"; /** - * Middleware to force authentication on server requests (including server functions), and add the user to the context. + * Middleware to enforce authentication on server requests (including server functions), and add the user to the context. */ export const authMiddleware = createMiddleware().server(async ({ next }) => { const session = await auth.api.getSession({ diff --git a/src/lib/auth/queries.ts b/packages/auth/src/tanstack/queries.ts similarity index 99% rename from src/lib/auth/queries.ts rename to packages/auth/src/tanstack/queries.ts index 1448109..6bf4123 100644 --- a/src/lib/auth/queries.ts +++ b/packages/auth/src/tanstack/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; + import { $getUser } from "./functions"; export const authQueryOptions = () => diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000..6df1a17 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@repo/tsconfig/base.json", + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/db/.env.example b/packages/db/.env.example new file mode 100644 index 0000000..d15dfc9 --- /dev/null +++ b/packages/db/.env.example @@ -0,0 +1,2 @@ +# For drizzle-kit migrations +SERVER_DATABASE_URL="postgresql://postgres:password@localhost:5432/tanstarter" \ No newline at end of file diff --git a/drizzle.config.ts b/packages/db/drizzle.config.ts similarity index 62% rename from drizzle.config.ts rename to packages/db/drizzle.config.ts index ca126ab..a6710eb 100644 --- a/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -1,15 +1,15 @@ import type { Config } from "drizzle-kit"; -import { env } from "~/env/server"; export default { - out: "./drizzle", - schema: "./src/lib/db/schema/index.ts", + out: "./migrations", + schema: "./src/schema/index.ts", breakpoints: true, verbose: true, strict: true, - dialect: "postgresql", casing: "snake_case", + + dialect: "postgresql", dbCredentials: { - url: env.DATABASE_URL, + url: process.env.SERVER_DATABASE_URL as string, }, } satisfies Config; diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..788e965 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,28 @@ +{ + "name": "@repo/db", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./schema": "./src/schema/index.ts" + }, + "scripts": { + "db": "drizzle-kit", + "auth:generate": "pnpm dlx https://pkg.pr.new/better-auth/better-auth/@better-auth/cli@6913 generate --config ../auth/src/auth.ts --y --output ./src/schema/auth.schema.ts" + }, + "dependencies": { + "drizzle-orm": "catalog:", + "nanoid": "catalog:", + "postgres": "catalog:" + }, + "devDependencies": { + "@repo/tsconfig": "workspace:*", + "@types/node": "catalog:", + "drizzle-kit": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "@tanstack/react-start": "catalog:" + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..1cda5d4 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,19 @@ +import "@tanstack/react-start/server-only"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import * as schemas from "./schema"; +import { relations } from "./schema/relations"; + +const { relations: authRelations, ...schema } = schemas; + +const client = postgres(process.env.SERVER_DATABASE_URL as string); + +export const db = drizzle({ + client, + schema, + // authRelations must come first, since it's using defineRelations as the main relation + // https://orm.drizzle.team/docs/relations-v2#relations-parts + relations: { ...authRelations, ...relations }, + casing: "snake_case", +}); diff --git a/src/lib/db/schema/auth.schema.ts b/packages/db/src/schema/auth.schema.ts similarity index 79% rename from src/lib/db/schema/auth.schema.ts rename to packages/db/src/schema/auth.schema.ts index 92103da..28b460c 100644 --- a/src/lib/db/schema/auth.schema.ts +++ b/packages/db/src/schema/auth.schema.ts @@ -1,5 +1,5 @@ -import { relations } from "drizzle-orm"; -import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { defineRelations } from "drizzle-orm"; +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), @@ -73,21 +73,27 @@ export const verification = pgTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); -export const userRelations = relations(user, ({ many }) => ({ - sessions: many(session), - accounts: many(account), -})); - -export const sessionRelations = relations(session, ({ one }) => ({ - user: one(user, { - fields: [session.userId], - references: [user.id], - }), -})); - -export const accountRelations = relations(account, ({ one }) => ({ - user: one(user, { - fields: [account.userId], - references: [user.id], - }), +export const relations = defineRelations({ user, session, account, verification }, (r) => ({ + user: { + sessions: r.many.session({ + from: r.user.id, + to: r.session.userId, + }), + accounts: r.many.account({ + from: r.user.id, + to: r.account.userId, + }), + }, + session: { + user: r.one.user({ + from: r.session.userId, + to: r.user.id, + }), + }, + account: { + user: r.one.user({ + from: r.account.userId, + to: r.user.id, + }), + }, })); diff --git a/src/lib/db/schema/index.ts b/packages/db/src/schema/index.ts similarity index 100% rename from src/lib/db/schema/index.ts rename to packages/db/src/schema/index.ts diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts new file mode 100644 index 0000000..5367130 --- /dev/null +++ b/packages/db/src/schema/relations.ts @@ -0,0 +1,8 @@ +import { defineRelationsPart } from "drizzle-orm"; + +import * as schema from "./"; + +export const relations = defineRelationsPart(schema, (r) => ({ + // Define your relations here + // https://orm.drizzle.team/docs/relations-v2 +})); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..6df1a17 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@repo/tsconfig/base.json", + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 0000000..9c2f7a6 --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-maia", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "styles/base.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "remixicon", + "aliases": { + "components": "@repo/ui/components", + "utils": "@repo/ui/lib/utils", + "ui": "@repo/ui/components", + "lib": "@repo/ui/lib", + "hooks": "@repo/ui/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "rtl": false, + "registries": {} +} diff --git a/packages/ui/components/button.tsx b/packages/ui/components/button.tsx new file mode 100644 index 0000000..587054d --- /dev/null +++ b/packages/ui/components/button.tsx @@ -0,0 +1,55 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cn } from "@repo/ui/lib/utils"; +import { cva, type VariantProps } from "class-variance-authority"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", + xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-9", + "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/src/components/ui/dropdown-menu.tsx b/packages/ui/components/dropdown-menu.tsx similarity index 59% rename from src/components/ui/dropdown-menu.tsx rename to packages/ui/components/dropdown-menu.tsx index 61d43ab..774669e 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/components/dropdown-menu.tsx @@ -1,9 +1,8 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu"; +import { RiArrowRightSLine, RiCheckLine } from "@remixicon/react"; +import { cn } from "@repo/ui/lib/utils"; import * as React from "react"; -import { CheckIcon, ChevronRightIcon } from "lucide-react"; -import { cn } from "~/lib/utils"; - function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { return ; } @@ -37,7 +36,7 @@ function DropdownMenuContent({ ); @@ -86,7 +82,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", className, )} {...props} @@ -111,13 +107,13 @@ function DropdownMenuSubTrigger({ data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( - "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex cursor-default items-center gap-2 rounded-xl px-3 py-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} > {children} - + ); } @@ -134,7 +130,7 @@ function DropdownMenuSubContent({ - + {children} @@ -182,13 +182,17 @@ function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { function DropdownMenuRadioItem({ className, children, + inset, ...props -}: MenuPrimitive.RadioItem.Props) { +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { return ( - + {children} @@ -210,7 +214,7 @@ function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator. return ( ); @@ -221,7 +225,7 @@ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"spa ) { + return ( + + ); +} + +export { Input }; diff --git a/src/components/ui/label.tsx b/packages/ui/components/label.tsx similarity index 81% rename from src/components/ui/label.tsx rename to packages/ui/components/label.tsx index 2eb658c..c3018f5 100644 --- a/src/components/ui/label.tsx +++ b/packages/ui/components/label.tsx @@ -1,11 +1,9 @@ -"use client"; - +import { cn } from "@repo/ui/lib/utils"; import * as React from "react"; -import { cn } from "~/lib/utils"; - function Label({ className, ...props }: React.ComponentProps<"label">) { return ( + // oxlint-disable-next-line jsx_a11y/label-has-associated-control