├── .env.example ├── .github ├── DISCUSSION_TEMPLATE │ └── ideas.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── renovate.json └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── apps ├── expo │ ├── .expo-shared │ │ └── assets.json │ ├── .prettierignore │ ├── app.config.ts │ ├── assets │ │ ├── icon-dark.png │ │ └── icon-light.png │ ├── babel.config.js │ ├── eas.json │ ├── eslint.config.mjs │ ├── index.ts │ ├── metro.config.js │ ├── nativewind-env.d.ts │ ├── package.json │ ├── src │ │ ├── app │ │ │ ├── _layout.tsx │ │ │ ├── index.tsx │ │ │ └── post │ │ │ │ └── [id].tsx │ │ ├── styles.css │ │ └── utils │ │ │ ├── api.tsx │ │ │ ├── auth.ts │ │ │ ├── base-url.ts │ │ │ └── session-store.ts │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json └── nextjs │ ├── README.md │ ├── eslint.config.js │ ├── next.config.js │ ├── package.json │ ├── postcss.config.cjs │ ├── public │ ├── favicon.ico │ └── t3-icon.svg │ ├── src │ ├── app │ │ ├── _components │ │ │ ├── auth-showcase.tsx │ │ │ └── posts.tsx │ │ ├── api │ │ │ ├── auth │ │ │ │ └── [...all] │ │ │ │ │ └── route.ts │ │ │ └── trpc │ │ │ │ └── [trpc] │ │ │ │ └── route.ts │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── auth │ │ ├── client.ts │ │ └── server.ts │ ├── env.ts │ └── trpc │ │ ├── query-client.ts │ │ ├── react.tsx │ │ └── server.tsx │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── package.json ├── packages ├── api │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── root.ts │ │ ├── router │ │ │ ├── auth.ts │ │ │ └── post.ts │ │ └── trpc.ts │ └── tsconfig.json ├── auth │ ├── env.ts │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── db │ ├── drizzle.config.ts │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── auth-schema.ts │ │ ├── client.ts │ │ ├── index.ts │ │ └── schema.ts │ └── tsconfig.json ├── ui │ ├── components.json │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── button.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── index.ts │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── theme.tsx │ │ └── toast.tsx │ └── tsconfig.json └── validators │ ├── eslint.config.js │ ├── package.json │ ├── src │ └── index.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tooling ├── eslint │ ├── base.js │ ├── nextjs.js │ ├── package.json │ ├── react.js │ ├── tsconfig.json │ └── types.d.ts ├── github │ ├── package.json │ └── setup │ │ └── action.yml ├── prettier │ ├── index.js │ ├── package.json │ └── tsconfig.json ├── tailwind │ ├── base.ts │ ├── eslint.config.js │ ├── native.ts │ ├── package.json │ ├── tsconfig.json │ └── web.ts └── typescript │ ├── base.json │ ├── internal-package.json │ └── package.json ├── turbo.json └── turbo └── generators ├── config.ts └── templates ├── eslint.config.js.hbs ├── package.json.hbs └── tsconfig.json.hbs /.env.example: -------------------------------------------------------------------------------- 1 | # Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. 2 | # Keep this file up-to-date when you add new variables to \`.env\`. 3 | 4 | # This file will be committed to version control, so make sure not to have any secrets in it. 5 | # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. 6 | 7 | # The database URL is used to connect to your Supabase database. 8 | POSTGRES_URL="postgres://postgres.[USERNAME]:[PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres?workaround=supabase-pooler.vercel" 9 | 10 | 11 | # You can generate the secret via 'openssl rand -base64 32' on Unix 12 | # @see https://www.better-auth.com/docs/installation 13 | AUTH_SECRET='supersecret' 14 | 15 | # Preconfigured Discord OAuth provider, works out-of-the-box 16 | # @see https://www.better-auth.com/docs/authentication/discord 17 | AUTH_DISCORD_ID='' 18 | AUTH_DISCORD_SECRET='' -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/ideas.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: markdown 3 | attributes: 4 | value: | 5 | Thank you for taking the time to file a feature request. Please fill out this form as completely as possible. 6 | - type: textarea 7 | attributes: 8 | label: Describe the feature you'd like to request 9 | description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed. 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Describe the solution you'd like to see 15 | description: Please describe the solution you would like to see. Adding example usage is a good way to provide context. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Additional information 21 | description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: juliusmarminge 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: Create a bug report to help us improve 3 | title: "bug: " 4 | labels: ["🐞❔ unconfirmed bug"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Provide environment information 9 | description: | 10 | Run this command in your project root and paste the results in a code block: 11 | ```bash 12 | npx envinfo --system --binaries 13 | ``` 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Describe the bug 19 | description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. 20 | validations: 21 | required: true 22 | - type: input 23 | attributes: 24 | label: Link to reproduction 25 | description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: To reproduce 31 | description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc. 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Additional information 37 | description: Add any other information related to the bug here, screenshots if applicable. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Ask a question 3 | url: https://github.com/t3-oss/create-t3-turbo/discussions 4 | about: Ask questions and discuss with other community members 5 | - name: Feature request 6 | url: https://github.com/t3-oss/create-t3-turbo/discussions/new?category=ideas 7 | about: Feature requests should be opened as discussions 8 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "packageRules": [ 5 | { 6 | "matchPackagePatterns": ["^@acme/"], 7 | "enabled": false 8 | } 9 | ], 10 | "updateInternalDeps": true, 11 | "rangeStrategy": "bump", 12 | "automerge": true, 13 | "npm": { 14 | "fileMatch": ["(^|/)package\\.json$", "(^|/)package\\.json\\.hbs$"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: ["*"] 6 | push: 7 | branches: ["main"] 8 | merge_group: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 13 | 14 | # You can leverage Vercel Remote Caching with Turbo to speed up your builds 15 | # @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds 16 | env: 17 | FORCE_COLOR: 3 18 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 19 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 20 | 21 | jobs: 22 | lint: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Setup 28 | uses: ./tooling/github/setup 29 | 30 | - name: Copy env 31 | shell: bash 32 | run: cp .env.example .env 33 | 34 | - name: Lint 35 | run: pnpm lint && pnpm lint:ws 36 | 37 | format: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - name: Setup 43 | uses: ./tooling/github/setup 44 | 45 | - name: Format 46 | run: pnpm format 47 | 48 | typecheck: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - name: Setup 54 | uses: ./tooling/github/setup 55 | 56 | - name: Typecheck 57 | run: pnpm typecheck 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | next-env.d.ts 15 | 16 | # nitro 17 | .nitro/ 18 | .output/ 19 | 20 | # expo 21 | .expo/ 22 | expo-env.d.ts 23 | apps/expo/.gitignore 24 | apps/expo/ios 25 | apps/expo/android 26 | 27 | # production 28 | build 29 | 30 | # misc 31 | .DS_Store 32 | *.pem 33 | 34 | # debug 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | .pnpm-debug.log* 39 | 40 | # local env files 41 | .env 42 | .env*.local 43 | 44 | # vercel 45 | .vercel 46 | 47 | # typescript 48 | dist/ 49 | .cache 50 | 51 | # turbo 52 | .turbo 53 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | node-linker=hoisted 2 | link-workspace-packages=true 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "expo.vscode-expo-tools", 5 | "esbenp.prettier-vscode", 6 | "yoavbls.pretty-ts-errors", 7 | "bradlc.vscode-tailwindcss" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "pnpm dev", 9 | "cwd": "${workspaceFolder}/apps/nextjs", 10 | "skipFiles": ["/**"], 11 | "sourceMaps": true, 12 | "sourceMapPathOverrides": { 13 | "/turbopack/[project]/*": "${webRoot}/*" //https://github.com/vercel/next.js/issues/62008 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "[typescript,typescriptreact]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "editor.formatOnSave": true, 10 | "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], 11 | "eslint.runtime": "node", 12 | "eslint.workingDirectories": [ 13 | { "pattern": "apps/*/" }, 14 | { "pattern": "packages/*/" }, 15 | { "pattern": "tooling/*/" } 16 | ], 17 | "prettier.ignorePath": ".gitignore", 18 | "tailwindCSS.classFunctions": ["cva", "cx", "cn"], 19 | "tailwindCSS.experimental.configFile": "./tooling/tailwind/web.ts", 20 | "typescript.enablePromptUseWorkspaceTsdk": true, 21 | "typescript.preferences.autoImportFileExcludePatterns": [ 22 | "zod/dist/types/index.d.ts", // Force auto-imports to v4 23 | "next/router.d.ts", 24 | "next/dist/client/router.d.ts" 25 | ], 26 | "typescript.tsdk": "node_modules/typescript/lib" 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Julius Marminge 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # create-t3-turbo 2 | 3 | > [!NOTE] 4 | > 5 | > create-t3-turbo now uses better-auth for authentication! 6 | > Look out for bugs as we're working through the last issues, 7 | > especially, the oauth proxy might not play very nice with Expo 8 | > so you might need to disable that in [`@acme/auth`](./packages/auth/src/index.ts) 9 | 10 | ## Installation 11 | 12 | > [!NOTE] 13 | > 14 | > Make sure to follow the system requirements specified in [`package.json#engines`](./package.json#L4) before proceeding. 15 | 16 | There are two ways of initializing an app using the `create-t3-turbo` starter. You can either use this repository as a template: 17 | 18 | ![use-as-template](https://github.com/t3-oss/create-t3-turbo/assets/51714798/bb6c2e5d-d8b6-416e-aeb3-b3e50e2ca994) 19 | 20 | or use Turbo's CLI to init your project (use PNPM as package manager): 21 | 22 | ```bash 23 | npx create-turbo@latest -e https://github.com/t3-oss/create-t3-turbo 24 | ``` 25 | 26 | ## About 27 | 28 | Ever wondered how to migrate your T3 application into a monorepo? Stop right here! This is the perfect starter repo to get you running with the perfect stack! 29 | 30 | It uses [Turborepo](https://turborepo.org) and contains: 31 | 32 | ```text 33 | .github 34 | └─ workflows 35 | └─ CI with pnpm cache setup 36 | .vscode 37 | └─ Recommended extensions and settings for VSCode users 38 | apps 39 | ├─ expo 40 | | ├─ Expo SDK 53 (EXPERIMENTAL) 41 | | | > [!WARNING] 42 | | | > Using Expo SDK 53 (canary) to unblock Next.js 15 / React 19 support. 43 | | | > This is experimental and might not work as expected. 44 | | ├─ React Native using React 19 45 | | ├─ Navigation using Expo Router 46 | | ├─ Tailwind using NativeWind 47 | | └─ Typesafe API calls using tRPC 48 | └─ next.js 49 | ├─ Next.js 15 50 | ├─ React 19 51 | ├─ Tailwind CSS 52 | └─ E2E Typesafe API Server & Client 53 | packages 54 | ├─ api 55 | | └─ tRPC v11 router definition 56 | ├─ auth 57 | | └─ Authentication using better-auth. 58 | ├─ db 59 | | └─ Typesafe db calls using Drizzle & Supabase 60 | └─ ui 61 | └─ Start of a UI package for the webapp using shadcn-ui 62 | tooling 63 | ├─ eslint 64 | | └─ shared, fine-grained, eslint presets 65 | ├─ prettier 66 | | └─ shared prettier configuration 67 | ├─ tailwind 68 | | └─ shared tailwind configuration 69 | └─ typescript 70 | └─ shared tsconfig you can extend from 71 | ``` 72 | 73 | > In this template, we use `@acme` as a placeholder for package names. As a user, you might want to replace it with your own organization or project name. You can use find-and-replace to change all the instances of `@acme` to something like `@my-company` or `@project-name`. 74 | 75 | ## Quick Start 76 | 77 | > **Note** 78 | > The [db](./packages/db) package is preconfigured to use Supabase and is **edge-bound** with the [Vercel Postgres](https://github.com/vercel/storage/tree/main/packages/postgres) driver. If you're using something else, make the necessary modifications to the [schema](./packages/db/src/schema.ts) as well as the [client](./packages/db/src/index.ts) and the [drizzle config](./packages/db/drizzle.config.ts). If you want to switch to non-edge database driver, remove `export const runtime = "edge";` [from all pages and api routes](https://github.com/t3-oss/create-t3-turbo/issues/634#issuecomment-1730240214). 79 | 80 | To get it running, follow the steps below: 81 | 82 | ### 1. Setup dependencies 83 | 84 | ```bash 85 | # Install dependencies 86 | pnpm i 87 | 88 | # Configure environment variables 89 | # There is an `.env.example` in the root directory you can use for reference 90 | cp .env.example .env 91 | 92 | # Push the Drizzle schema to the database 93 | pnpm db:push 94 | ``` 95 | 96 | ### 2. Configure Expo `dev`-script 97 | 98 | #### Use iOS Simulator 99 | 100 | 1. Make sure you have XCode and XCommand Line Tools installed [as shown on expo docs](https://docs.expo.dev/workflow/ios-simulator). 101 | 102 | > **NOTE:** If you just installed XCode, or if you have updated it, you need to open the simulator manually once. Run `npx expo start` from `apps/expo`, and then enter `I` to launch Expo Go. After the manual launch, you can run `pnpm dev` in the root directory. 103 | 104 | ```diff 105 | + "dev": "expo start --ios", 106 | ``` 107 | 108 | 2. Run `pnpm dev` at the project root folder. 109 | 110 | #### Use Android Emulator 111 | 112 | 1. Install Android Studio tools [as shown on expo docs](https://docs.expo.dev/workflow/android-studio-emulator). 113 | 114 | 2. Change the `dev` script at `apps/expo/package.json` to open the Android emulator. 115 | 116 | ```diff 117 | + "dev": "expo start --android", 118 | ``` 119 | 120 | 3. Run `pnpm dev` at the project root folder. 121 | 122 | ### 3. Configuring Better-Auth to work with Expo 123 | 124 | In order to get Better-Auth to work with Expo, you must either: 125 | 126 | #### Deploy the Auth Proxy (RECOMMENDED) 127 | 128 | Better-auth comes with an [auth proxy plugin](https://www.better-auth.com/docs/plugins/oauth-proxy). By deploying the Next.js app, you can get OAuth working in preview deployments and development for Expo apps. 129 | 130 | By using the proxy plugin, the Next.js apps will forward any auth requests to the proxy server, which will handle the OAuth flow and then redirect back to the Next.js app. This makes it easy to get OAuth working since you'll have a stable URL that is publicly accessible and doesn't change for every deployment and doesn't rely on what port the app is running on. So if port 3000 is taken and your Next.js app starts at port 3001 instead, your auth should still work without having to reconfigure the OAuth provider. 131 | 132 | #### Add your local IP to your OAuth provider 133 | 134 | You can alternatively add your local IP (e.g. `192.168.x.y:$PORT`) to your OAuth provider. This may not be as reliable as your local IP may change when you change networks. Some OAuth providers may also only support a single callback URL for each app making this approach unviable for some providers (e.g. GitHub). 135 | 136 | ### 4a. When it's time to add a new UI component 137 | 138 | Run the `ui-add` script to add a new UI component using the interactive `shadcn/ui` CLI: 139 | 140 | ```bash 141 | pnpm ui-add 142 | ``` 143 | 144 | When the component(s) has been installed, you should be good to go and start using it in your app. 145 | 146 | ### 4b. When it's time to add a new package 147 | 148 | To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as well as if you want to install any dependencies to the new package (of course you can also do this yourself later). 149 | 150 | The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary configurations for tooling around your package such as formatting, linting and typechecking. When the package is created, you're ready to go build out the package. 151 | 152 | ## FAQ 153 | 154 | ### Does the starter include Solito? 155 | 156 | No. Solito will not be included in this repo. It is a great tool if you want to share code between your Next.js and Expo app. However, the main purpose of this repo is not the integration between Next.js and Expo — it's the code splitting of your T3 App into a monorepo. The Expo app is just a bonus example of how you can utilize the monorepo with multiple apps but can just as well be any app such as Vite, Electron, etc. 157 | 158 | Integrating Solito into this repo isn't hard, and there are a few [official templates](https://github.com/nandorojo/solito/tree/master/example-monorepos) by the creators of Solito that you can use as a reference. 159 | 160 | ### Does this pattern leak backend code to my client applications? 161 | 162 | No, it does not. The `api` package should only be a production dependency in the Next.js application where it's served. The Expo app, and all other apps you may add in the future, should only add the `api` package as a dev dependency. This lets you have full typesafety in your client applications, while keeping your backend code safe. 163 | 164 | If you need to share runtime code between the client and server, such as input validation schemas, you can create a separate `shared` package for this and import it on both sides. 165 | 166 | ## Deployment 167 | 168 | ### Next.js 169 | 170 | #### Prerequisites 171 | 172 | > **Note** 173 | > Please note that the Next.js application with tRPC must be deployed in order for the Expo app to communicate with the server in a production environment. 174 | 175 | #### Deploy to Vercel 176 | 177 | Let's deploy the Next.js application to [Vercel](https://vercel.com). If you've never deployed a Turborepo app there, don't worry, the steps are quite straightforward. You can also read the [official Turborepo guide](https://vercel.com/docs/concepts/monorepos/turborepo) on deploying to Vercel. 178 | 179 | 1. Create a new project on Vercel, select the `apps/nextjs` folder as the root directory. Vercel's zero-config system should handle all configurations for you. 180 | 181 | 2. Add your `POSTGRES_URL` environment variable. 182 | 183 | 3. Done! Your app should successfully deploy. Assign your domain and use that instead of `localhost` for the `url` in the Expo app so that your Expo app can communicate with your backend when you are not in development. 184 | 185 | ### Auth Proxy 186 | 187 | The auth proxy comes as a better-auth plugin. This is required for the Next.js app to be able to authenticate users in preview deployments. The auth proxy is not used for OAuth request in production deployments. The easiest way to get it running is to deploy the Next.js app to vercel. 188 | 189 | ### Expo 190 | 191 | Deploying your Expo application works slightly differently compared to Next.js on the web. Instead of "deploying" your app online, you need to submit production builds of your app to app stores, like [Apple App Store](https://www.apple.com/app-store) and [Google Play](https://play.google.com/store/apps). You can read the full [guide to distributing your app](https://docs.expo.dev/distribution/introduction), including best practices, in the Expo docs. 192 | 193 | 1. Make sure to modify the `getBaseUrl` function to point to your backend's production URL: 194 | 195 | 196 | 197 | 2. Let's start by setting up [EAS Build](https://docs.expo.dev/build/introduction), which is short for Expo Application Services. The build service helps you create builds of your app, without requiring a full native development setup. The commands below are a summary of [Creating your first build](https://docs.expo.dev/build/setup). 198 | 199 | ```bash 200 | # Install the EAS CLI 201 | pnpm add -g eas-cli 202 | 203 | # Log in with your Expo account 204 | eas login 205 | 206 | # Configure your Expo app 207 | cd apps/expo 208 | eas build:configure 209 | ``` 210 | 211 | 3. After the initial setup, you can create your first build. You can build for Android and iOS platforms and use different [`eas.json` build profiles](https://docs.expo.dev/build-reference/eas-json) to create production builds or development, or test builds. Let's make a production build for iOS. 212 | 213 | ```bash 214 | eas build --platform ios --profile production 215 | ``` 216 | 217 | > If you don't specify the `--profile` flag, EAS uses the `production` profile by default. 218 | 219 | 4. Now that you have your first production build, you can submit this to the stores. [EAS Submit](https://docs.expo.dev/submit/introduction) can help you send the build to the stores. 220 | 221 | ```bash 222 | eas submit --platform ios --latest 223 | ``` 224 | 225 | > You can also combine build and submit in a single command, using `eas build ... --auto-submit`. 226 | 227 | 5. Before you can get your app in the hands of your users, you'll have to provide additional information to the app stores. This includes screenshots, app information, privacy policies, etc. _While still in preview_, [EAS Metadata](https://docs.expo.dev/eas/metadata) can help you with most of this information. 228 | 229 | 6. Once everything is approved, your users can finally enjoy your app. Let's say you spotted a small typo; you'll have to create a new build, submit it to the stores, and wait for approval before you can resolve this issue. In these cases, you can use EAS Update to quickly send a small bugfix to your users without going through this long process. Let's start by setting up EAS Update. 230 | 231 | The steps below summarize the [Getting started with EAS Update](https://docs.expo.dev/eas-update/getting-started/#configure-your-project) guide. 232 | 233 | ```bash 234 | # Add the `expo-updates` library to your Expo app 235 | cd apps/expo 236 | pnpm expo install expo-updates 237 | 238 | # Configure EAS Update 239 | eas update:configure 240 | ``` 241 | 242 | 7. Before we can send out updates to your app, you have to create a new build and submit it to the app stores. For every change that includes native APIs, you have to rebuild the app and submit the update to the app stores. See steps 2 and 3. 243 | 244 | 8. Now that everything is ready for updates, let's create a new update for `production` builds. With the `--auto` flag, EAS Update uses your current git branch name and commit message for this update. See [How EAS Update works](https://docs.expo.dev/eas-update/how-eas-update-works/#publishing-an-update) for more information. 245 | 246 | ```bash 247 | cd apps/expo 248 | eas update --auto 249 | ``` 250 | 251 | > Your OTA (Over The Air) updates must always follow the app store's rules. You can't change your app's primary functionality without getting app store approval. But this is a fast way to update your app for minor changes and bug fixes. 252 | 253 | 9. Done! Now that you have created your production build, submitted it to the stores, and installed EAS Update, you are ready for anything! 254 | 255 | ## References 256 | 257 | The stack originates from [create-t3-app](https://github.com/t3-oss/create-t3-app). 258 | 259 | A [blog post](https://jumr.dev/blog/t3-turbo) where I wrote how to migrate a T3 app into this. 260 | -------------------------------------------------------------------------------- /apps/expo/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /apps/expo/.prettierignore: -------------------------------------------------------------------------------- 1 | nativewind-env.d.ts 2 | -------------------------------------------------------------------------------- /apps/expo/app.config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigContext, ExpoConfig } from "expo/config"; 2 | 3 | export default ({ config }: ConfigContext): ExpoConfig => ({ 4 | ...config, 5 | name: "expo", 6 | slug: "expo", 7 | scheme: "expo", 8 | version: "0.1.0", 9 | orientation: "portrait", 10 | icon: "./assets/icon-light.png", 11 | userInterfaceStyle: "automatic", 12 | updates: { 13 | fallbackToCacheTimeout: 0, 14 | }, 15 | newArchEnabled: true, 16 | assetBundlePatterns: ["**/*"], 17 | ios: { 18 | bundleIdentifier: "your.bundle.identifier", 19 | supportsTablet: true, 20 | icon: { 21 | light: "./assets/icon-light.png", 22 | dark: "./assets/icon-dark.png", 23 | }, 24 | }, 25 | android: { 26 | package: "your.bundle.identifier", 27 | adaptiveIcon: { 28 | foregroundImage: "./assets/icon-light.png", 29 | backgroundColor: "#1F104A", 30 | }, 31 | edgeToEdgeEnabled: true, 32 | }, 33 | // extra: { 34 | // eas: { 35 | // projectId: "your-eas-project-id", 36 | // }, 37 | // }, 38 | experiments: { 39 | tsconfigPaths: true, 40 | typedRoutes: true, 41 | }, 42 | plugins: [ 43 | "expo-router", 44 | "expo-secure-store", 45 | "expo-web-browser", 46 | [ 47 | "expo-splash-screen", 48 | { 49 | backgroundColor: "#E4E4E7", 50 | image: "./assets/icon-light.png", 51 | dark: { 52 | backgroundColor: "#18181B", 53 | image: "./assets/icon-dark.png", 54 | }, 55 | }, 56 | ], 57 | ], 58 | }); 59 | -------------------------------------------------------------------------------- /apps/expo/assets/icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t3-oss/create-t3-turbo/26ca48be2034d3599c83850e2f950ce945a985b5/apps/expo/assets/icon-dark.png -------------------------------------------------------------------------------- /apps/expo/assets/icon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t3-oss/create-t3-turbo/26ca48be2034d3599c83850e2f950ce945a985b5/apps/expo/assets/icon-light.png -------------------------------------------------------------------------------- /apps/expo/babel.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("@babel/core").ConfigFunction} */ 2 | module.exports = (api) => { 3 | api.cache(true); 4 | return { 5 | presets: [ 6 | ["babel-preset-expo", { jsxImportSource: "nativewind" }], 7 | "nativewind/babel", 8 | ], 9 | plugins: ["react-native-reanimated/plugin"], 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/expo/eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 4.1.2", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "base": { 8 | "node": "22.12.0", 9 | "pnpm": "9.15.4", 10 | "ios": { 11 | "resourceClass": "m-medium" 12 | } 13 | }, 14 | "development": { 15 | "extends": "base", 16 | "developmentClient": true, 17 | "distribution": "internal" 18 | }, 19 | "preview": { 20 | "extends": "base", 21 | "distribution": "internal", 22 | "ios": { 23 | "simulator": true 24 | } 25 | }, 26 | "production": { 27 | "extends": "base" 28 | } 29 | }, 30 | "submit": { 31 | "production": {} 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/expo/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from "@acme/eslint-config/base"; 2 | import reactConfig from "@acme/eslint-config/react"; 3 | 4 | /** @type {import('typescript-eslint').Config} */ 5 | export default [ 6 | { 7 | ignores: [".expo/**", "expo-plugins/**"], 8 | }, 9 | ...baseConfig, 10 | ...reactConfig, 11 | ]; 12 | -------------------------------------------------------------------------------- /apps/expo/index.ts: -------------------------------------------------------------------------------- 1 | import "expo-router/entry"; 2 | -------------------------------------------------------------------------------- /apps/expo/metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more: https://docs.expo.dev/guides/monorepos/ 2 | const { getDefaultConfig } = require("expo/metro-config"); 3 | const { FileStore } = require("metro-cache"); 4 | const { withNativeWind } = require("nativewind/metro"); 5 | 6 | const path = require("node:path"); 7 | 8 | const config = withTurborepoManagedCache( 9 | withNativeWind(getDefaultConfig(__dirname), { 10 | input: "./src/styles.css", 11 | configPath: "./tailwind.config.ts", 12 | }), 13 | ); 14 | module.exports = config; 15 | 16 | /** 17 | * Move the Metro cache to the `.cache/metro` folder. 18 | * If you have any environment variables, you can configure Turborepo to invalidate it when needed. 19 | * 20 | * @see https://turborepo.com/docs/reference/configuration#env 21 | * @param {import('expo/metro-config').MetroConfig} config 22 | * @returns {import('expo/metro-config').MetroConfig} 23 | */ 24 | function withTurborepoManagedCache(config) { 25 | config.cacheStores = [ 26 | new FileStore({ root: path.join(__dirname, ".cache/metro") }), 27 | ]; 28 | return config; 29 | } 30 | -------------------------------------------------------------------------------- /apps/expo/nativewind-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind. 4 | -------------------------------------------------------------------------------- /apps/expo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acme/expo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "index.ts", 6 | "scripts": { 7 | "clean": "git clean -xdf .cache .expo .turbo android ios node_modules", 8 | "dev": "expo start", 9 | "dev:android": "expo start --android", 10 | "dev:ios": "expo start --ios", 11 | "android": "expo run:android", 12 | "ios": "expo run:ios", 13 | "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore", 14 | "lint": "eslint", 15 | "typecheck": "tsc --noEmit" 16 | }, 17 | "dependencies": { 18 | "@better-auth/expo": "1.2.9", 19 | "@expo/metro-config": "^0.20.14", 20 | "@legendapp/list": "^1.0.15", 21 | "@tanstack/react-query": "catalog:", 22 | "@trpc/client": "catalog:", 23 | "@trpc/server": "catalog:", 24 | "@trpc/tanstack-react-query": "catalog:", 25 | "better-auth": "1.2.9", 26 | "expo": "53.0.9", 27 | "expo-constants": "17.1.6", 28 | "expo-dev-client": "5.1.8", 29 | "expo-linking": "7.1.5", 30 | "expo-router": "5.0.7", 31 | "expo-secure-store": "14.2.3", 32 | "expo-splash-screen": "0.30.8", 33 | "expo-status-bar": "2.2.3", 34 | "expo-system-ui": "~5.0.7", 35 | "expo-web-browser": "14.1.6", 36 | "nativewind": "~4.1.23", 37 | "react": "catalog:react19", 38 | "react-dom": "catalog:react19", 39 | "react-native": "0.79.2", 40 | "react-native-gesture-handler": "~2.25.0", 41 | "react-native-reanimated": "~3.18.0", 42 | "react-native-safe-area-context": "~5.4.1", 43 | "react-native-screens": "~4.11.1", 44 | "superjson": "2.2.2" 45 | }, 46 | "devDependencies": { 47 | "@acme/api": "workspace:*", 48 | "@acme/eslint-config": "workspace:*", 49 | "@acme/prettier-config": "workspace:*", 50 | "@acme/tailwind-config": "workspace:*", 51 | "@acme/tsconfig": "workspace:*", 52 | "@babel/core": "^7.27.4", 53 | "@babel/preset-env": "^7.27.2", 54 | "@babel/runtime": "^7.27.4", 55 | "@types/babel__core": "^7.20.5", 56 | "@types/react": "catalog:react19", 57 | "eslint": "catalog:", 58 | "prettier": "catalog:", 59 | "tailwindcss": "catalog:", 60 | "typescript": "catalog:" 61 | }, 62 | "prettier": "@acme/prettier-config" 63 | } 64 | -------------------------------------------------------------------------------- /apps/expo/src/app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | import { StatusBar } from "expo-status-bar"; 3 | import { useColorScheme } from "nativewind"; 4 | 5 | import { queryClient } from "~/utils/api"; 6 | 7 | import "../styles.css"; 8 | 9 | import { QueryClientProvider } from "@tanstack/react-query"; 10 | 11 | // This is the main layout of the app 12 | // It wraps your pages with the providers they need 13 | export default function RootLayout() { 14 | const { colorScheme } = useColorScheme(); 15 | return ( 16 | 17 | {/* 18 | The Stack component displays the current page. 19 | It also allows you to configure your screens 20 | */} 21 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /apps/expo/src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Button, Pressable, Text, TextInput, View } from "react-native"; 3 | import { SafeAreaView } from "react-native-safe-area-context"; 4 | import { Link, Stack } from "expo-router"; 5 | import { LegendList } from "@legendapp/list"; 6 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 7 | 8 | import type { RouterOutputs } from "~/utils/api"; 9 | import { trpc } from "~/utils/api"; 10 | import { authClient } from "~/utils/auth"; 11 | 12 | function PostCard(props: { 13 | post: RouterOutputs["post"]["all"][number]; 14 | onDelete: () => void; 15 | }) { 16 | return ( 17 | 18 | 19 | 26 | 27 | 28 | {props.post.title} 29 | 30 | {props.post.content} 31 | 32 | 33 | 34 | 35 | Delete 36 | 37 | 38 | ); 39 | } 40 | 41 | function CreatePost() { 42 | const queryClient = useQueryClient(); 43 | 44 | const [title, setTitle] = useState(""); 45 | const [content, setContent] = useState(""); 46 | 47 | const { mutate, error } = useMutation( 48 | trpc.post.create.mutationOptions({ 49 | async onSuccess() { 50 | setTitle(""); 51 | setContent(""); 52 | await queryClient.invalidateQueries(trpc.post.all.queryFilter()); 53 | }, 54 | }), 55 | ); 56 | 57 | return ( 58 | 59 | 65 | {error?.data?.zodError?.fieldErrors.title && ( 66 | 67 | {error.data.zodError.fieldErrors.title} 68 | 69 | )} 70 | 76 | {error?.data?.zodError?.fieldErrors.content && ( 77 | 78 | {error.data.zodError.fieldErrors.content} 79 | 80 | )} 81 | { 84 | mutate({ 85 | title, 86 | content, 87 | }); 88 | }} 89 | > 90 | Create 91 | 92 | {error?.data?.code === "UNAUTHORIZED" && ( 93 | 94 | You need to be logged in to create a post 95 | 96 | )} 97 | 98 | ); 99 | } 100 | 101 | function MobileAuth() { 102 | const { data: session } = authClient.useSession(); 103 | 104 | return ( 105 | <> 106 | 107 | {session?.user.name ? `Hello, ${session.user.name}` : "Not logged in"} 108 | 109 | 32 | 33 | ); 34 | } 35 | 36 | return ( 37 |
38 |

39 | Logged in as {session.user.name} 40 |

41 | 42 |
43 | 55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/_components/posts.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | useMutation, 5 | useQueryClient, 6 | useSuspenseQuery, 7 | } from "@tanstack/react-query"; 8 | 9 | import type { RouterOutputs } from "@acme/api"; 10 | import { CreatePostSchema } from "@acme/db/schema"; 11 | import { cn } from "@acme/ui"; 12 | import { Button } from "@acme/ui/button"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormMessage, 19 | useForm, 20 | } from "@acme/ui/form"; 21 | import { Input } from "@acme/ui/input"; 22 | import { toast } from "@acme/ui/toast"; 23 | 24 | import { useTRPC } from "~/trpc/react"; 25 | 26 | export function CreatePostForm() { 27 | const trpc = useTRPC(); 28 | const form = useForm({ 29 | schema: CreatePostSchema, 30 | defaultValues: { 31 | content: "", 32 | title: "", 33 | }, 34 | }); 35 | 36 | const queryClient = useQueryClient(); 37 | const createPost = useMutation( 38 | trpc.post.create.mutationOptions({ 39 | onSuccess: async () => { 40 | form.reset(); 41 | await queryClient.invalidateQueries(trpc.post.pathFilter()); 42 | }, 43 | onError: (err) => { 44 | toast.error( 45 | err.data?.code === "UNAUTHORIZED" 46 | ? "You must be logged in to post" 47 | : "Failed to create post", 48 | ); 49 | }, 50 | }), 51 | ); 52 | 53 | return ( 54 |
55 | { 58 | createPost.mutate(data); 59 | })} 60 | > 61 | ( 65 | 66 | 67 | 68 | 69 | 70 | 71 | )} 72 | /> 73 | ( 77 | 78 | 79 | 80 | 81 | 82 | 83 | )} 84 | /> 85 | 86 | 87 | 88 | ); 89 | } 90 | 91 | export function PostList() { 92 | const trpc = useTRPC(); 93 | const { data: posts } = useSuspenseQuery(trpc.post.all.queryOptions()); 94 | 95 | if (posts.length === 0) { 96 | return ( 97 |
98 | 99 | 100 | 101 | 102 |
103 |

No posts yet

104 |
105 |
106 | ); 107 | } 108 | 109 | return ( 110 |
111 | {posts.map((p) => { 112 | return ; 113 | })} 114 |
115 | ); 116 | } 117 | 118 | export function PostCard(props: { 119 | post: RouterOutputs["post"]["all"][number]; 120 | }) { 121 | const trpc = useTRPC(); 122 | const queryClient = useQueryClient(); 123 | const deletePost = useMutation( 124 | trpc.post.delete.mutationOptions({ 125 | onSuccess: async () => { 126 | await queryClient.invalidateQueries(trpc.post.pathFilter()); 127 | }, 128 | onError: (err) => { 129 | toast.error( 130 | err.data?.code === "UNAUTHORIZED" 131 | ? "You must be logged in to delete a post" 132 | : "Failed to delete post", 133 | ); 134 | }, 135 | }), 136 | ); 137 | 138 | return ( 139 |
140 |
141 |

{props.post.title}

142 |

{props.post.content}

143 |
144 |
145 | 152 |
153 |
154 | ); 155 | } 156 | 157 | export function PostCardSkeleton(props: { pulse?: boolean }) { 158 | const { pulse = true } = props; 159 | return ( 160 |
161 |
162 |

168 |   169 |

170 |

176 |   177 |

178 |
179 |
180 | ); 181 | } 182 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "~/auth/server"; 2 | 3 | export const GET = auth.handler; 4 | export const POST = auth.handler; 5 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 3 | 4 | import { appRouter, createTRPCContext } from "@acme/api"; 5 | 6 | import { auth } from "~/auth/server"; 7 | 8 | /** 9 | * Configure basic CORS headers 10 | * You should extend this to match your needs 11 | */ 12 | const setCorsHeaders = (res: Response) => { 13 | res.headers.set("Access-Control-Allow-Origin", "*"); 14 | res.headers.set("Access-Control-Request-Method", "*"); 15 | res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST"); 16 | res.headers.set("Access-Control-Allow-Headers", "*"); 17 | }; 18 | 19 | export const OPTIONS = () => { 20 | const response = new Response(null, { 21 | status: 204, 22 | }); 23 | setCorsHeaders(response); 24 | return response; 25 | }; 26 | 27 | const handler = async (req: NextRequest) => { 28 | const response = await fetchRequestHandler({ 29 | endpoint: "/api/trpc", 30 | router: appRouter, 31 | req, 32 | createContext: () => 33 | createTRPCContext({ 34 | auth: auth, 35 | headers: req.headers, 36 | }), 37 | onError({ error, path }) { 38 | console.error(`>>> tRPC Error on '${path}'`, error); 39 | }, 40 | }); 41 | 42 | setCorsHeaders(response); 43 | return response; 44 | }; 45 | 46 | export { handler as GET, handler as POST }; 47 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 327 66% 69%; 14 | --primary-foreground: 337 65.5% 17.1%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 72.22% 50.59%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 5% 64.9%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 240 10% 3.9%; 31 | --foreground: 0 0% 98%; 32 | --card: 240 10% 3.9%; 33 | --card-foreground: 0 0% 98%; 34 | --popover: 240 10% 3.9%; 35 | --popover-foreground: 0 0% 98%; 36 | --primary: 327 66% 69%; 37 | --primary-foreground: 337 65.5% 17.1%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 240 3.7% 15.9%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 240 3.7% 15.9%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 85.7% 97.3%; 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 240 4.9% 83.9%; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, Viewport } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | 4 | import { cn } from "@acme/ui"; 5 | import { ThemeProvider, ThemeToggle } from "@acme/ui/theme"; 6 | import { Toaster } from "@acme/ui/toast"; 7 | 8 | import { TRPCReactProvider } from "~/trpc/react"; 9 | 10 | import "~/app/globals.css"; 11 | 12 | import { env } from "~/env"; 13 | 14 | export const metadata: Metadata = { 15 | metadataBase: new URL( 16 | env.VERCEL_ENV === "production" 17 | ? "https://turbo.t3.gg" 18 | : "http://localhost:3000", 19 | ), 20 | title: "Create T3 Turbo", 21 | description: "Simple monorepo with shared backend for web & mobile apps", 22 | openGraph: { 23 | title: "Create T3 Turbo", 24 | description: "Simple monorepo with shared backend for web & mobile apps", 25 | url: "https://create-t3-turbo.vercel.app", 26 | siteName: "Create T3 Turbo", 27 | }, 28 | twitter: { 29 | card: "summary_large_image", 30 | site: "@jullerino", 31 | creator: "@jullerino", 32 | }, 33 | }; 34 | 35 | export const viewport: Viewport = { 36 | themeColor: [ 37 | { media: "(prefers-color-scheme: light)", color: "white" }, 38 | { media: "(prefers-color-scheme: dark)", color: "black" }, 39 | ], 40 | }; 41 | 42 | const geistSans = Geist({ 43 | subsets: ["latin"], 44 | variable: "--font-geist-sans", 45 | }); 46 | const geistMono = Geist_Mono({ 47 | subsets: ["latin"], 48 | variable: "--font-geist-mono", 49 | }); 50 | 51 | export default function RootLayout(props: { children: React.ReactNode }) { 52 | return ( 53 | 54 | 61 | 62 | {props.children} 63 |
64 | 65 |
66 | 67 |
68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | 3 | import { HydrateClient, prefetch, trpc } from "~/trpc/server"; 4 | import { AuthShowcase } from "./_components/auth-showcase"; 5 | import { 6 | CreatePostForm, 7 | PostCardSkeleton, 8 | PostList, 9 | } from "./_components/posts"; 10 | 11 | export default function HomePage() { 12 | prefetch(trpc.post.all.queryOptions()); 13 | 14 | return ( 15 | 16 |
17 |
18 |

19 | Create T3 Turbo 20 |

21 | 22 | 23 | 24 |
25 | 28 | 29 | 30 | 31 |
32 | } 33 | > 34 | 35 | 36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/nextjs/src/auth/client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | 3 | export const authClient = createAuthClient(); 4 | -------------------------------------------------------------------------------- /apps/nextjs/src/auth/server.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import { cache } from "react"; 4 | import { headers } from "next/headers"; 5 | 6 | import { initAuth } from "@acme/auth"; 7 | 8 | import { env } from "~/env"; 9 | 10 | const baseUrl = 11 | env.VERCEL_ENV === "production" 12 | ? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}` 13 | : env.VERCEL_ENV === "preview" 14 | ? `https://${env.VERCEL_URL}` 15 | : "http://localhost:3000"; 16 | 17 | export const auth = initAuth({ 18 | baseUrl, 19 | productionUrl: `https://${env.VERCEL_PROJECT_PRODUCTION_URL ?? "turbo.t3.gg"}`, 20 | secret: env.AUTH_SECRET, 21 | discordClientId: env.AUTH_DISCORD_ID, 22 | discordClientSecret: env.AUTH_DISCORD_SECRET, 23 | }); 24 | 25 | export const getSession = cache(async () => 26 | auth.api.getSession({ headers: await headers() }), 27 | ); 28 | -------------------------------------------------------------------------------- /apps/nextjs/src/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { vercel } from "@t3-oss/env-nextjs/presets-zod"; 3 | import { z } from "zod/v4"; 4 | 5 | import { authEnv } from "@acme/auth/env"; 6 | 7 | export const env = createEnv({ 8 | extends: [authEnv(), vercel()], 9 | shared: { 10 | NODE_ENV: z 11 | .enum(["development", "production", "test"]) 12 | .default("development"), 13 | }, 14 | /** 15 | * Specify your server-side environment variables schema here. 16 | * This way you can ensure the app isn't built with invalid env vars. 17 | */ 18 | server: { 19 | POSTGRES_URL: z.string().url(), 20 | }, 21 | 22 | /** 23 | * Specify your client-side environment variables schema here. 24 | * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. 25 | */ 26 | client: { 27 | // NEXT_PUBLIC_CLIENTVAR: z.string(), 28 | }, 29 | /** 30 | * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. 31 | */ 32 | experimental__runtimeEnv: { 33 | NODE_ENV: process.env.NODE_ENV, 34 | 35 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 36 | }, 37 | skipValidation: 38 | !!process.env.CI || process.env.npm_lifecycle_event === "lint", 39 | }); 40 | -------------------------------------------------------------------------------- /apps/nextjs/src/trpc/query-client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultShouldDehydrateQuery, 3 | QueryClient, 4 | } from "@tanstack/react-query"; 5 | import SuperJSON from "superjson"; 6 | 7 | export const createQueryClient = () => 8 | new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | // With SSR, we usually want to set some default staleTime 12 | // above 0 to avoid refetching immediately on the client 13 | staleTime: 30 * 1000, 14 | }, 15 | dehydrate: { 16 | serializeData: SuperJSON.serialize, 17 | shouldDehydrateQuery: (query) => 18 | defaultShouldDehydrateQuery(query) || 19 | query.state.status === "pending", 20 | shouldRedactErrors: () => { 21 | // We should not catch Next.js server errors 22 | // as that's how Next.js detects dynamic pages 23 | // so we cannot redact them. 24 | // Next.js also automatically redacts errors for us 25 | // with better digests. 26 | return false; 27 | }, 28 | }, 29 | hydrate: { 30 | deserializeData: SuperJSON.deserialize, 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /apps/nextjs/src/trpc/react.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { QueryClient } from "@tanstack/react-query"; 4 | import { useState } from "react"; 5 | import { QueryClientProvider } from "@tanstack/react-query"; 6 | import { 7 | createTRPCClient, 8 | httpBatchStreamLink, 9 | loggerLink, 10 | } from "@trpc/client"; 11 | import { createTRPCContext } from "@trpc/tanstack-react-query"; 12 | import SuperJSON from "superjson"; 13 | 14 | import type { AppRouter } from "@acme/api"; 15 | 16 | import { env } from "~/env"; 17 | import { createQueryClient } from "./query-client"; 18 | 19 | let clientQueryClientSingleton: QueryClient | undefined = undefined; 20 | const getQueryClient = () => { 21 | if (typeof window === "undefined") { 22 | // Server: always make a new query client 23 | return createQueryClient(); 24 | } else { 25 | // Browser: use singleton pattern to keep the same query client 26 | return (clientQueryClientSingleton ??= createQueryClient()); 27 | } 28 | }; 29 | 30 | export const { useTRPC, TRPCProvider } = createTRPCContext(); 31 | 32 | export function TRPCReactProvider(props: { children: React.ReactNode }) { 33 | const queryClient = getQueryClient(); 34 | 35 | const [trpcClient] = useState(() => 36 | createTRPCClient({ 37 | links: [ 38 | loggerLink({ 39 | enabled: (op) => 40 | env.NODE_ENV === "development" || 41 | (op.direction === "down" && op.result instanceof Error), 42 | }), 43 | httpBatchStreamLink({ 44 | transformer: SuperJSON, 45 | url: getBaseUrl() + "/api/trpc", 46 | headers() { 47 | const headers = new Headers(); 48 | headers.set("x-trpc-source", "nextjs-react"); 49 | return headers; 50 | }, 51 | }), 52 | ], 53 | }), 54 | ); 55 | 56 | return ( 57 | 58 | 59 | {props.children} 60 | 61 | 62 | ); 63 | } 64 | 65 | const getBaseUrl = () => { 66 | if (typeof window !== "undefined") return window.location.origin; 67 | if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`; 68 | // eslint-disable-next-line no-restricted-properties 69 | return `http://localhost:${process.env.PORT ?? 3000}`; 70 | }; 71 | -------------------------------------------------------------------------------- /apps/nextjs/src/trpc/server.tsx: -------------------------------------------------------------------------------- 1 | import type { TRPCQueryOptions } from "@trpc/tanstack-react-query"; 2 | import { cache } from "react"; 3 | import { headers } from "next/headers"; 4 | import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; 5 | import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; 6 | 7 | import type { AppRouter } from "@acme/api"; 8 | import { appRouter, createTRPCContext } from "@acme/api"; 9 | 10 | import { auth } from "~/auth/server"; 11 | import { createQueryClient } from "./query-client"; 12 | 13 | /** 14 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 15 | * handling a tRPC call from a React Server Component. 16 | */ 17 | const createContext = cache(async () => { 18 | const heads = new Headers(await headers()); 19 | heads.set("x-trpc-source", "rsc"); 20 | 21 | return createTRPCContext({ 22 | headers: heads, 23 | auth, 24 | }); 25 | }); 26 | 27 | const getQueryClient = cache(createQueryClient); 28 | 29 | export const trpc = createTRPCOptionsProxy({ 30 | router: appRouter, 31 | ctx: createContext, 32 | queryClient: getQueryClient, 33 | }); 34 | 35 | export function HydrateClient(props: { children: React.ReactNode }) { 36 | const queryClient = getQueryClient(); 37 | return ( 38 | 39 | {props.children} 40 | 41 | ); 42 | } 43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 44 | export function prefetch>>( 45 | queryOptions: T, 46 | ) { 47 | const queryClient = getQueryClient(); 48 | if (queryOptions.queryKey[1]?.type === "infinite") { 49 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any 50 | void queryClient.prefetchInfiniteQuery(queryOptions as any); 51 | } else { 52 | void queryClient.prefetchQuery(queryOptions); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /apps/nextjs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import { fontFamily } from "tailwindcss/defaultTheme"; 3 | 4 | import baseConfig from "@acme/tailwind-config/web"; 5 | 6 | export default { 7 | // We need to append the path to the UI package to the content array so that 8 | // those classes are included correctly. 9 | content: [...baseConfig.content, "../../packages/ui/src/*.{ts,tsx}"], 10 | presets: [baseConfig], 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | sans: ["var(--font-geist-sans)", ...fontFamily.sans], 15 | mono: ["var(--font-geist-mono)", ...fontFamily.mono], 16 | }, 17 | }, 18 | }, 19 | } satisfies Config; 20 | -------------------------------------------------------------------------------- /apps/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@acme/tsconfig/base.json", 3 | "compilerOptions": { 4 | "lib": ["ES2022", "dom", "dom.iterable"], 5 | "jsx": "preserve", 6 | "baseUrl": ".", 7 | "paths": { 8 | "~/*": ["./src/*"] 9 | }, 10 | "plugins": [{ "name": "next" }], 11 | "module": "esnext" 12 | }, 13 | "include": [".", ".next/types/**/*.ts"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /apps/nextjs/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "extends": ["//"], 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": [".next/**", "!.next/cache/**", "next-env.d.ts"] 8 | }, 9 | "dev": { 10 | "persistent": true 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-t3-turbo", 3 | "private": true, 4 | "engines": { 5 | "node": ">=22.14.0", 6 | "pnpm": ">=9.6.0" 7 | }, 8 | "packageManager": "pnpm@10.11.1", 9 | "scripts": { 10 | "build": "turbo run build", 11 | "clean": "git clean -xdf node_modules", 12 | "clean:workspaces": "turbo run clean", 13 | "auth:generate": "pnpm -F @acme/auth generate", 14 | "db:push": "turbo -F @acme/db push", 15 | "db:studio": "turbo -F @acme/db studio", 16 | "dev": "turbo watch dev --continue", 17 | "dev:next": "turbo watch dev -F @acme/nextjs...", 18 | "format": "turbo run format --continue -- --cache --cache-location .cache/.prettiercache", 19 | "format:fix": "turbo run format --continue -- --write --cache --cache-location .cache/.prettiercache", 20 | "lint": "turbo run lint --continue -- --cache --cache-location .cache/.eslintcache", 21 | "lint:fix": "turbo run lint --continue -- --fix --cache --cache-location .cache/.eslintcache", 22 | "lint:ws": "pnpm dlx sherif@latest", 23 | "postinstall": "pnpm lint:ws", 24 | "typecheck": "turbo run typecheck", 25 | "ui-add": "turbo run ui-add", 26 | "android": "expo run:android", 27 | "ios": "expo run:ios" 28 | }, 29 | "devDependencies": { 30 | "@acme/prettier-config": "workspace:*", 31 | "@turbo/gen": "^2.5.4", 32 | "prettier": "catalog:", 33 | "turbo": "^2.5.4", 34 | "typescript": "catalog:" 35 | }, 36 | "prettier": "@acme/prettier-config" 37 | } 38 | -------------------------------------------------------------------------------- /packages/api/eslint.config.js: -------------------------------------------------------------------------------- 1 | import baseConfig from "@acme/eslint-config/base"; 2 | 3 | /** @type {import('typescript-eslint').Config} */ 4 | export default [ 5 | { 6 | ignores: ["dist/**"], 7 | }, 8 | ...baseConfig, 9 | ]; 10 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acme/api", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "types": "./dist/index.d.ts", 9 | "default": "./src/index.ts" 10 | } 11 | }, 12 | "license": "MIT", 13 | "scripts": { 14 | "build": "tsc", 15 | "clean": "git clean -xdf .cache .turbo dist node_modules", 16 | "dev": "tsc", 17 | "format": "prettier --check . --ignore-path ../../.gitignore", 18 | "lint": "eslint", 19 | "typecheck": "tsc --noEmit --emitDeclarationOnly false" 20 | }, 21 | "dependencies": { 22 | "@acme/auth": "workspace:*", 23 | "@acme/db": "workspace:*", 24 | "@acme/validators": "workspace:*", 25 | "@trpc/server": "catalog:", 26 | "superjson": "2.2.2", 27 | "zod": "catalog:" 28 | }, 29 | "devDependencies": { 30 | "@acme/eslint-config": "workspace:*", 31 | "@acme/prettier-config": "workspace:*", 32 | "@acme/tsconfig": "workspace:*", 33 | "eslint": "catalog:", 34 | "prettier": "catalog:", 35 | "typescript": "catalog:" 36 | }, 37 | "prettier": "@acme/prettier-config" 38 | } 39 | -------------------------------------------------------------------------------- /packages/api/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; 2 | 3 | import type { AppRouter } from "./root"; 4 | import { appRouter } from "./root"; 5 | import { createTRPCContext } from "./trpc"; 6 | 7 | /** 8 | * Inference helpers for input types 9 | * @example 10 | * type PostByIdInput = RouterInputs['post']['byId'] 11 | * ^? { id: number } 12 | **/ 13 | type RouterInputs = inferRouterInputs; 14 | 15 | /** 16 | * Inference helpers for output types 17 | * @example 18 | * type AllPostsOutput = RouterOutputs['post']['all'] 19 | * ^? Post[] 20 | **/ 21 | type RouterOutputs = inferRouterOutputs; 22 | 23 | export { createTRPCContext, appRouter }; 24 | export type { AppRouter, RouterInputs, RouterOutputs }; 25 | -------------------------------------------------------------------------------- /packages/api/src/root.ts: -------------------------------------------------------------------------------- 1 | import { authRouter } from "./router/auth"; 2 | import { postRouter } from "./router/post"; 3 | import { createTRPCRouter } from "./trpc"; 4 | 5 | export const appRouter = createTRPCRouter({ 6 | auth: authRouter, 7 | post: postRouter, 8 | }); 9 | 10 | // export type definition of API 11 | export type AppRouter = typeof appRouter; 12 | -------------------------------------------------------------------------------- /packages/api/src/router/auth.ts: -------------------------------------------------------------------------------- 1 | import type { TRPCRouterRecord } from "@trpc/server"; 2 | 3 | import { protectedProcedure, publicProcedure } from "../trpc"; 4 | 5 | export const authRouter = { 6 | getSession: publicProcedure.query(({ ctx }) => { 7 | return ctx.session; 8 | }), 9 | getSecretMessage: protectedProcedure.query(() => { 10 | return "you can see this secret message!"; 11 | }), 12 | } satisfies TRPCRouterRecord; 13 | -------------------------------------------------------------------------------- /packages/api/src/router/post.ts: -------------------------------------------------------------------------------- 1 | import type { TRPCRouterRecord } from "@trpc/server"; 2 | import { z } from "zod/v4"; 3 | 4 | import { desc, eq } from "@acme/db"; 5 | import { CreatePostSchema, Post } from "@acme/db/schema"; 6 | 7 | import { protectedProcedure, publicProcedure } from "../trpc"; 8 | 9 | export const postRouter = { 10 | all: publicProcedure.query(({ ctx }) => { 11 | return ctx.db.query.Post.findMany({ 12 | orderBy: desc(Post.id), 13 | limit: 10, 14 | }); 15 | }), 16 | 17 | byId: publicProcedure 18 | .input(z.object({ id: z.string() })) 19 | .query(({ ctx, input }) => { 20 | return ctx.db.query.Post.findFirst({ 21 | where: eq(Post.id, input.id), 22 | }); 23 | }), 24 | 25 | create: protectedProcedure 26 | .input(CreatePostSchema) 27 | .mutation(({ ctx, input }) => { 28 | return ctx.db.insert(Post).values(input); 29 | }), 30 | 31 | delete: protectedProcedure.input(z.string()).mutation(({ ctx, input }) => { 32 | return ctx.db.delete(Post).where(eq(Post.id, input)); 33 | }), 34 | } satisfies TRPCRouterRecord; 35 | -------------------------------------------------------------------------------- /packages/api/src/trpc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: 3 | * 1. You want to modify request context (see Part 1) 4 | * 2. You want to create a new middleware or type of procedure (see Part 3) 5 | * 6 | * tl;dr - this is where all the tRPC server stuff is created and plugged in. 7 | * The pieces you will need to use are documented accordingly near the end 8 | */ 9 | import { initTRPC, TRPCError } from "@trpc/server"; 10 | import superjson from "superjson"; 11 | import { z, ZodError } from "zod/v4"; 12 | 13 | import type { Auth } from "@acme/auth"; 14 | import { db } from "@acme/db/client"; 15 | 16 | /** 17 | * 1. CONTEXT 18 | * 19 | * This section defines the "contexts" that are available in the backend API. 20 | * 21 | * These allow you to access things when processing a request, like the database, the session, etc. 22 | * 23 | * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each 24 | * wrap this and provides the required context. 25 | * 26 | * @see https://trpc.io/docs/server/context 27 | */ 28 | 29 | export const createTRPCContext = async (opts: { 30 | headers: Headers; 31 | auth: Auth; 32 | }) => { 33 | const authApi = opts.auth.api; 34 | const session = await authApi.getSession({ 35 | headers: opts.headers, 36 | }); 37 | return { 38 | authApi, 39 | session, 40 | db, 41 | }; 42 | }; 43 | /** 44 | * 2. INITIALIZATION 45 | * 46 | * This is where the trpc api is initialized, connecting the context and 47 | * transformer 48 | */ 49 | const t = initTRPC.context().create({ 50 | transformer: superjson, 51 | errorFormatter: ({ shape, error }) => ({ 52 | ...shape, 53 | data: { 54 | ...shape.data, 55 | zodError: 56 | error.cause instanceof ZodError 57 | ? z.flattenError(error.cause as ZodError>) 58 | : null, 59 | }, 60 | }), 61 | }); 62 | 63 | /** 64 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 65 | * 66 | * These are the pieces you use to build your tRPC API. You should import these 67 | * a lot in the /src/server/api/routers folder 68 | */ 69 | 70 | /** 71 | * This is how you create new routers and subrouters in your tRPC API 72 | * @see https://trpc.io/docs/router 73 | */ 74 | export const createTRPCRouter = t.router; 75 | 76 | /** 77 | * Middleware for timing procedure execution and adding an articifial delay in development. 78 | * 79 | * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating 80 | * network latency that would occur in production but not in local development. 81 | */ 82 | const timingMiddleware = t.middleware(async ({ next, path }) => { 83 | const start = Date.now(); 84 | 85 | if (t._config.isDev) { 86 | // artificial delay in dev 100-500ms 87 | const waitMs = Math.floor(Math.random() * 400) + 100; 88 | await new Promise((resolve) => setTimeout(resolve, waitMs)); 89 | } 90 | 91 | const result = await next(); 92 | 93 | const end = Date.now(); 94 | console.log(`[TRPC] ${path} took ${end - start}ms to execute`); 95 | 96 | return result; 97 | }); 98 | 99 | /** 100 | * Public (unauthed) procedure 101 | * 102 | * This is the base piece you use to build new queries and mutations on your 103 | * tRPC API. It does not guarantee that a user querying is authorized, but you 104 | * can still access user session data if they are logged in 105 | */ 106 | export const publicProcedure = t.procedure.use(timingMiddleware); 107 | 108 | /** 109 | * Protected (authenticated) procedure 110 | * 111 | * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies 112 | * the session is valid and guarantees `ctx.session.user` is not null. 113 | * 114 | * @see https://trpc.io/docs/procedures 115 | */ 116 | export const protectedProcedure = t.procedure 117 | .use(timingMiddleware) 118 | .use(({ ctx, next }) => { 119 | if (!ctx.session?.user) { 120 | throw new TRPCError({ code: "UNAUTHORIZED" }); 121 | } 122 | return next({ 123 | ctx: { 124 | // infers the `session` as non-nullable 125 | session: { ...ctx.session, user: ctx.session.user }, 126 | }, 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@acme/tsconfig/internal-package.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/auth/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod/v4"; 3 | 4 | export function authEnv() { 5 | return createEnv({ 6 | server: { 7 | AUTH_DISCORD_ID: z.string().min(1), 8 | AUTH_DISCORD_SECRET: z.string().min(1), 9 | AUTH_SECRET: 10 | process.env.NODE_ENV === "production" 11 | ? z.string().min(1) 12 | : z.string().min(1).optional(), 13 | NODE_ENV: z.enum(["development", "production"]).optional(), 14 | }, 15 | experimental__runtimeEnv: {}, 16 | skipValidation: 17 | !!process.env.CI || process.env.npm_lifecycle_event === "lint", 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /packages/auth/eslint.config.js: -------------------------------------------------------------------------------- 1 | import baseConfig, { restrictEnvAccess } from "@acme/eslint-config/base"; 2 | 3 | /** @type {import('typescript-eslint').Config} */ 4 | export default [ 5 | { 6 | ignores: [], 7 | }, 8 | ...baseConfig, 9 | ...restrictEnvAccess, 10 | ]; 11 | -------------------------------------------------------------------------------- /packages/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acme/auth", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts", 8 | "./middleware": "./src/middleware.ts", 9 | "./client": "./src/client.ts", 10 | "./env": "./env.ts" 11 | }, 12 | "license": "MIT", 13 | "scripts": { 14 | "clean": "git clean -xdf .cache .turbo dist node_modules", 15 | "format": "prettier --check . --ignore-path ../../.gitignore", 16 | "lint": "eslint", 17 | "generate": "dotenv -e ../../.env -- pnpx @better-auth/cli generate --output ../db/src/auth-schema.ts", 18 | "typecheck": "tsc --noEmit" 19 | }, 20 | "dependencies": { 21 | "@acme/db": "workspace:*", 22 | "@better-auth/expo": "1.2.9", 23 | "@t3-oss/env-nextjs": "^0.13.6", 24 | "better-auth": "1.2.9", 25 | "next": "^15.3.3", 26 | "react": "catalog:react19", 27 | "react-dom": "catalog:react19", 28 | "zod": "catalog:" 29 | }, 30 | "devDependencies": { 31 | "@acme/eslint-config": "workspace:*", 32 | "@acme/prettier-config": "workspace:*", 33 | "@acme/tsconfig": "workspace:*", 34 | "@types/react": "catalog:react19", 35 | "eslint": "catalog:", 36 | "prettier": "catalog:", 37 | "typescript": "catalog:" 38 | }, 39 | "prettier": "@acme/prettier-config" 40 | } 41 | -------------------------------------------------------------------------------- /packages/auth/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { BetterAuthOptions } from "better-auth"; 2 | import { expo } from "@better-auth/expo"; 3 | import { betterAuth } from "better-auth"; 4 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 5 | import { oAuthProxy } from "better-auth/plugins"; 6 | 7 | import { db } from "@acme/db/client"; 8 | 9 | export function initAuth(options: { 10 | baseUrl: string; 11 | productionUrl: string; 12 | secret: string | undefined; 13 | 14 | discordClientId: string; 15 | discordClientSecret: string; 16 | }) { 17 | const config = { 18 | database: drizzleAdapter(db, { 19 | provider: "pg", 20 | }), 21 | baseURL: options.baseUrl, 22 | secret: options.secret, 23 | plugins: [ 24 | oAuthProxy({ 25 | /** 26 | * Auto-inference blocked by https://github.com/better-auth/better-auth/pull/2891 27 | */ 28 | currentURL: options.baseUrl, 29 | productionURL: options.productionUrl, 30 | }), 31 | expo(), 32 | ], 33 | socialProviders: { 34 | discord: { 35 | clientId: options.discordClientId, 36 | clientSecret: options.discordClientSecret, 37 | redirectURI: `${options.productionUrl}/api/auth/callback/discord`, 38 | }, 39 | }, 40 | trustedOrigins: ["expo://"], 41 | } satisfies BetterAuthOptions; 42 | 43 | return betterAuth(config); 44 | } 45 | 46 | export type Auth = ReturnType; 47 | export type Session = Auth["$Infer"]["Session"]; 48 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@acme/tsconfig/base.json", 3 | "include": ["src", "*.ts"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/db/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | if (!process.env.POSTGRES_URL) { 4 | throw new Error("Missing POSTGRES_URL"); 5 | } 6 | 7 | const nonPoolingUrl = process.env.POSTGRES_URL.replace(":6543", ":5432"); 8 | 9 | export default { 10 | schema: "./src/schema.ts", 11 | dialect: "postgresql", 12 | dbCredentials: { url: nonPoolingUrl }, 13 | casing: "snake_case", 14 | } satisfies Config; 15 | -------------------------------------------------------------------------------- /packages/db/eslint.config.js: -------------------------------------------------------------------------------- 1 | import baseConfig from "@acme/eslint-config/base"; 2 | 3 | /** @type {import('typescript-eslint').Config} */ 4 | export default [ 5 | { 6 | ignores: ["dist/**"], 7 | }, 8 | ...baseConfig, 9 | ]; 10 | -------------------------------------------------------------------------------- /packages/db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acme/db", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "types": "./dist/index.d.ts", 9 | "default": "./src/index.ts" 10 | }, 11 | "./client": { 12 | "types": "./dist/client.d.ts", 13 | "default": "./src/client.ts" 14 | }, 15 | "./schema": { 16 | "types": "./dist/schema.d.ts", 17 | "default": "./src/schema.ts" 18 | } 19 | }, 20 | "license": "MIT", 21 | "scripts": { 22 | "build": "tsc", 23 | "clean": "git clean -xdf .cache .turbo dist node_modules", 24 | "dev": "tsc", 25 | "format": "prettier --check . --ignore-path ../../.gitignore", 26 | "lint": "eslint", 27 | "push": "pnpm with-env drizzle-kit push", 28 | "studio": "pnpm with-env drizzle-kit studio", 29 | "typecheck": "tsc --noEmit --emitDeclarationOnly false", 30 | "with-env": "dotenv -e ../../.env --" 31 | }, 32 | "dependencies": { 33 | "@vercel/postgres": "^0.10.0", 34 | "drizzle-orm": "^0.44.1", 35 | "drizzle-zod": "^0.8.2", 36 | "zod": "catalog:" 37 | }, 38 | "devDependencies": { 39 | "@acme/eslint-config": "workspace:*", 40 | "@acme/prettier-config": "workspace:*", 41 | "@acme/tsconfig": "workspace:*", 42 | "dotenv-cli": "^8.0.0", 43 | "drizzle-kit": "^0.31.1", 44 | "eslint": "catalog:", 45 | "prettier": "catalog:", 46 | "typescript": "catalog:" 47 | }, 48 | "prettier": "@acme/prettier-config" 49 | } 50 | -------------------------------------------------------------------------------- /packages/db/src/auth-schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable } from "drizzle-orm/pg-core"; 2 | 3 | export const user = pgTable("user", (t) => ({ 4 | id: t.text().primaryKey(), 5 | name: t.text().notNull(), 6 | email: t.text().notNull().unique(), 7 | emailVerified: t.boolean().notNull(), 8 | image: t.text(), 9 | createdAt: t.timestamp().notNull(), 10 | updatedAt: t.timestamp().notNull(), 11 | })); 12 | 13 | export const session = pgTable("session", (t) => ({ 14 | id: t.text().primaryKey(), 15 | expiresAt: t.timestamp().notNull(), 16 | token: t.text().notNull().unique(), 17 | createdAt: t.timestamp().notNull(), 18 | updatedAt: t.timestamp().notNull(), 19 | ipAddress: t.text(), 20 | userAgent: t.text(), 21 | userId: t 22 | .text() 23 | .notNull() 24 | .references(() => user.id, { onDelete: "cascade" }), 25 | })); 26 | 27 | export const account = pgTable("account", (t) => ({ 28 | id: t.text().primaryKey(), 29 | accountId: t.text().notNull(), 30 | providerId: t.text().notNull(), 31 | userId: t 32 | .text() 33 | .notNull() 34 | .references(() => user.id, { onDelete: "cascade" }), 35 | accessToken: t.text(), 36 | refreshToken: t.text(), 37 | idToken: t.text(), 38 | accessTokenExpiresAt: t.timestamp(), 39 | refreshTokenExpiresAt: t.timestamp(), 40 | scope: t.text(), 41 | password: t.text(), 42 | createdAt: t.timestamp().notNull(), 43 | updatedAt: t.timestamp().notNull(), 44 | })); 45 | 46 | export const verification = pgTable("verification", (t) => ({ 47 | id: t.text().primaryKey(), 48 | identifier: t.text().notNull(), 49 | value: t.text().notNull(), 50 | expiresAt: t.timestamp().notNull(), 51 | createdAt: t.timestamp(), 52 | updatedAt: t.timestamp(), 53 | })); 54 | -------------------------------------------------------------------------------- /packages/db/src/client.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "@vercel/postgres"; 2 | import { drizzle } from "drizzle-orm/vercel-postgres"; 3 | 4 | import * as schema from "./schema"; 5 | 6 | export const db = drizzle({ 7 | client: sql, 8 | schema, 9 | casing: "snake_case", 10 | }); 11 | -------------------------------------------------------------------------------- /packages/db/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "drizzle-orm/sql"; 2 | export { alias } from "drizzle-orm/pg-core"; 3 | -------------------------------------------------------------------------------- /packages/db/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { pgTable } from "drizzle-orm/pg-core"; 3 | import { createInsertSchema } from "drizzle-zod"; 4 | import { z } from "zod/v4"; 5 | 6 | export const Post = pgTable("post", (t) => ({ 7 | id: t.uuid().notNull().primaryKey().defaultRandom(), 8 | title: t.varchar({ length: 256 }).notNull(), 9 | content: t.text().notNull(), 10 | createdAt: t.timestamp().defaultNow().notNull(), 11 | updatedAt: t 12 | .timestamp({ mode: "date", withTimezone: true }) 13 | .$onUpdateFn(() => sql`now()`), 14 | })); 15 | 16 | export const CreatePostSchema = createInsertSchema(Post, { 17 | title: z.string().max(256), 18 | content: z.string().max(256), 19 | }).omit({ 20 | id: true, 21 | createdAt: true, 22 | updatedAt: true, 23 | }); 24 | 25 | export * from "./auth-schema"; 26 | -------------------------------------------------------------------------------- /packages/db/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@acme/tsconfig/internal-package.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "../../tooling/tailwind/web.ts", 8 | "css": "unused.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "utils": "@acme/ui", 14 | "components": "src/", 15 | "ui": "src/" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/ui/eslint.config.js: -------------------------------------------------------------------------------- 1 | import baseConfig from "@acme/eslint-config/base"; 2 | import reactConfig from "@acme/eslint-config/react"; 3 | 4 | /** @type {import('typescript-eslint').Config} */ 5 | export default [ 6 | { 7 | ignores: ["dist/**"], 8 | }, 9 | ...baseConfig, 10 | ...reactConfig, 11 | ]; 12 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acme/ui", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts", 8 | "./button": "./src/button.tsx", 9 | "./dropdown-menu": "./src/dropdown-menu.tsx", 10 | "./form": "./src/form.tsx", 11 | "./input": "./src/input.tsx", 12 | "./label": "./src/label.tsx", 13 | "./theme": "./src/theme.tsx", 14 | "./toast": "./src/toast.tsx" 15 | }, 16 | "license": "MIT", 17 | "scripts": { 18 | "clean": "git clean -xdf .cache .turbo dist node_modules", 19 | "format": "prettier --check . --ignore-path ../../.gitignore", 20 | "lint": "eslint", 21 | "typecheck": "tsc --noEmit --emitDeclarationOnly false", 22 | "ui-add": "pnpm dlx shadcn@latest add && prettier src --write --list-different" 23 | }, 24 | "dependencies": { 25 | "@hookform/resolvers": "^5.0.1", 26 | "@radix-ui/react-icons": "^1.3.2", 27 | "class-variance-authority": "^0.7.1", 28 | "next-themes": "^0.4.6", 29 | "radix-ui": "^1.4.2", 30 | "react-hook-form": "^7.57.0", 31 | "sonner": "^2.0.5", 32 | "tailwind-merge": "^3.3.0" 33 | }, 34 | "devDependencies": { 35 | "@acme/eslint-config": "workspace:*", 36 | "@acme/prettier-config": "workspace:*", 37 | "@acme/tailwind-config": "workspace:*", 38 | "@acme/tsconfig": "workspace:*", 39 | "@types/react": "catalog:react19", 40 | "eslint": "catalog:", 41 | "prettier": "catalog:", 42 | "react": "catalog:react19", 43 | "typescript": "catalog:", 44 | "zod": "catalog:" 45 | }, 46 | "peerDependencies": { 47 | "react": "catalog:react19", 48 | "zod": "catalog:" 49 | }, 50 | "prettier": "@acme/prettier-config" 51 | } 52 | -------------------------------------------------------------------------------- /packages/ui/src/button.tsx: -------------------------------------------------------------------------------- 1 | import type { VariantProps } from "class-variance-authority"; 2 | import * as React from "react"; 3 | import { cva } from "class-variance-authority"; 4 | import { Slot } from "radix-ui"; 5 | 6 | import { cn } from "@acme/ui"; 7 | 8 | export const buttonVariants = cva( 9 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 10 | { 11 | variants: { 12 | variant: { 13 | primary: 14 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 15 | destructive: 16 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 17 | outline: 18 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 19 | secondary: 20 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 21 | ghost: "hover:bg-accent hover:text-accent-foreground", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | md: "h-9 px-4 py-2", 27 | lg: "h-10 rounded-md px-8", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "primary", 33 | size: "md", 34 | }, 35 | }, 36 | ); 37 | 38 | interface ButtonProps 39 | extends React.ComponentProps<"button">, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | export function Button({ 45 | className, 46 | variant, 47 | size, 48 | asChild = false, 49 | ...props 50 | }: ButtonProps) { 51 | const Comp = asChild ? Slot.Slot : "button"; 52 | return ( 53 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /packages/ui/src/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { 5 | CheckIcon, 6 | ChevronRightIcon, 7 | DotFilledIcon, 8 | } from "@radix-ui/react-icons"; 9 | import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; 10 | 11 | import { cn } from "@acme/ui"; 12 | 13 | export const DropdownMenu = DropdownMenuPrimitive.Root; 14 | export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 15 | export const DropdownMenuGroup = DropdownMenuPrimitive.Group; 16 | export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 17 | export const DropdownMenuSub = DropdownMenuPrimitive.Sub; 18 | export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 19 | 20 | export function DropdownMenuSubTrigger({ 21 | className, 22 | inset, 23 | children, 24 | ...props 25 | }: React.ComponentProps & { 26 | inset?: boolean; 27 | }) { 28 | return ( 29 | 37 | {children} 38 | 39 | 40 | ); 41 | } 42 | 43 | export function DropdownMenuSubContent({ 44 | className, 45 | ...props 46 | }: React.ComponentProps) { 47 | return ( 48 | 55 | ); 56 | } 57 | 58 | export function DropdownMenuContent({ 59 | className, 60 | sideOffset = 4, 61 | ...props 62 | }: React.ComponentProps) { 63 | return ( 64 | 65 | 74 | 75 | ); 76 | } 77 | 78 | export function DropdownMenuItem({ 79 | className, 80 | inset, 81 | ...props 82 | }: React.ComponentProps & { 83 | inset?: boolean; 84 | }) { 85 | return ( 86 | 94 | ); 95 | } 96 | 97 | export function DropdownMenuCheckboxItem({ 98 | className, 99 | children, 100 | ...props 101 | }: React.ComponentProps) { 102 | return ( 103 | 110 | 111 | 112 | 113 | 114 | 115 | {children} 116 | 117 | ); 118 | } 119 | 120 | export function DropdownMenuRadioItem({ 121 | className, 122 | children, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 133 | 134 | 135 | 136 | 137 | 138 | {children} 139 | 140 | ); 141 | } 142 | 143 | export function DropdownMenuLabel({ 144 | className, 145 | inset, 146 | ...props 147 | }: React.ComponentProps & { 148 | inset?: boolean; 149 | }) { 150 | return ( 151 | 159 | ); 160 | } 161 | 162 | export function DropdownMenuSeparator({ 163 | className, 164 | ...props 165 | }: React.ComponentProps) { 166 | return ( 167 | 171 | ); 172 | } 173 | 174 | export function DropdownMenuShortcut({ 175 | className, 176 | ...props 177 | }: React.ComponentProps<"span">) { 178 | return ( 179 | 183 | ); 184 | } 185 | -------------------------------------------------------------------------------- /packages/ui/src/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { 4 | ControllerProps, 5 | FieldPath, 6 | FieldValues, 7 | UseFormProps, 8 | } from "react-hook-form"; 9 | import type { ZodType } from "zod/v4"; 10 | import * as React from "react"; 11 | import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; 12 | import { Slot } from "radix-ui"; 13 | import { 14 | useForm as __useForm, 15 | Controller, 16 | useFormContext, 17 | } from "react-hook-form"; 18 | 19 | import { cn } from "@acme/ui"; 20 | 21 | import { Label } from "./label"; 22 | 23 | export { FormProvider as Form, useFieldArray } from "react-hook-form"; 24 | 25 | export function useForm( 26 | props: Omit, "resolver"> & { 27 | schema: ZodType; 28 | }, 29 | ) { 30 | const form = __useForm({ 31 | ...props, 32 | resolver: standardSchemaResolver(props.schema, undefined), 33 | }); 34 | 35 | return form; 36 | } 37 | 38 | interface FormFieldContextValue< 39 | TFieldValues extends FieldValues = FieldValues, 40 | TName extends FieldPath = FieldPath, 41 | > { 42 | name: TName; 43 | } 44 | 45 | const FormFieldContext = React.createContext( 46 | null, 47 | ); 48 | 49 | export function FormField< 50 | TFieldValues extends FieldValues = FieldValues, 51 | TName extends FieldPath = FieldPath, 52 | >({ ...props }: ControllerProps) { 53 | return ( 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | export function useFormField() { 61 | const { getFieldState, formState } = useFormContext(); 62 | 63 | const fieldContext = React.use(FormFieldContext); 64 | if (!fieldContext) { 65 | throw new Error("useFormField should be used within "); 66 | } 67 | const fieldState = getFieldState(fieldContext.name, formState); 68 | 69 | const itemContext = React.use(FormItemContext); 70 | const { id } = itemContext; 71 | 72 | return { 73 | id, 74 | name: fieldContext.name, 75 | formItemId: `${id}-form-item`, 76 | formDescriptionId: `${id}-form-item-description`, 77 | formMessageId: `${id}-form-item-message`, 78 | ...fieldState, 79 | }; 80 | } 81 | 82 | interface FormItemContextValue { 83 | id: string; 84 | } 85 | 86 | const FormItemContext = React.createContext( 87 | {} as FormItemContextValue, 88 | ); 89 | 90 | export function FormItem({ className, ...props }: React.ComponentProps<"div">) { 91 | const id = React.useId(); 92 | 93 | return ( 94 | 95 |
96 | 97 | ); 98 | } 99 | 100 | export function FormLabel({ 101 | className, 102 | ...props 103 | }: React.ComponentProps) { 104 | const { error, formItemId } = useFormField(); 105 | 106 | return ( 107 |