├── .dockerignore ├── .gitignore ├── LICENSE ├── README.md ├── apps ├── server │ ├── .env.example │ ├── .gitignore │ ├── Dockerfile │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── env.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── turbo.json └── web │ ├── .env.example │ ├── .gitignore │ ├── .prettierignore │ ├── Dockerfile │ ├── eslint.config.js │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── public │ ├── favicon.png │ └── healthcheck │ ├── src │ ├── clients │ │ ├── authClient.ts │ │ ├── queryClient.ts │ │ └── trpcClient.ts │ ├── env.ts │ ├── main.tsx │ ├── routeTree.gen.ts │ ├── router.tsx │ ├── routes │ │ ├── -components │ │ │ ├── common │ │ │ │ ├── form-field-info.tsx │ │ │ │ └── spinner.tsx │ │ │ └── layout │ │ │ │ └── nav │ │ │ │ ├── nav-container.tsx │ │ │ │ ├── navbar.tsx │ │ │ │ └── user-avatar.tsx │ │ ├── __root.tsx │ │ ├── _protected │ │ │ ├── layout.tsx │ │ │ └── posts │ │ │ │ ├── $postid │ │ │ │ └── index.tsx │ │ │ │ ├── -components │ │ │ │ ├── create-post.tsx │ │ │ │ └── delete-post.tsx │ │ │ │ ├── -validations │ │ │ │ └── posts-link-options.ts │ │ │ │ └── index.tsx │ │ ├── _public │ │ │ ├── -components │ │ │ │ ├── login-form.tsx │ │ │ │ └── register-form.tsx │ │ │ ├── layout.tsx │ │ │ ├── login.tsx │ │ │ └── register.tsx │ │ └── index.tsx │ ├── style.css │ └── vite-env.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── turbo.json │ └── vite.config.ts ├── compose.yaml ├── package.json ├── packages ├── api │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── client │ │ │ └── index.ts │ │ └── server │ │ │ ├── index.ts │ │ │ ├── router │ │ │ └── post.ts │ │ │ └── trpc.ts │ └── tsconfig.json ├── auth │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── cli-config.ts │ │ ├── client.ts │ │ └── server.ts │ └── tsconfig.json ├── db │ ├── .env.example │ ├── Dockerfile │ ├── drizzle.config.ts │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── client.ts │ │ ├── index.ts │ │ ├── schema.ts │ │ └── schemas │ │ │ ├── auth.ts │ │ │ └── posts.ts │ ├── tsconfig.drizzlekit.json │ ├── tsconfig.json │ ├── tsconfig.package.json │ └── turbo.json └── ui │ ├── components.json │ ├── eslint.config.js │ ├── package.json │ ├── src │ ├── components │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── sonner.tsx │ │ ├── textarea.tsx │ │ └── tooltip.tsx │ └── lib │ │ └── utils.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tools ├── eslint │ ├── base.js │ ├── package.json │ ├── react.js │ ├── tsconfig.json │ └── types.d.ts ├── prettier │ ├── index.js │ ├── package.json │ └── tsconfig.json ├── tailwind │ ├── eslint.config.js │ ├── package.json │ └── style.css └── typescript │ ├── base.json │ ├── internal-package.json │ ├── package.json │ └── vite.json └── turbo.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.cache/ 2 | **/.turbo/ 3 | **/.DS_Store 4 | 5 | **/.env* 6 | 7 | **/dist/ 8 | **/node_modules/ 9 | **/out/ 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | out/ 4 | 5 | .cache/ 6 | .turbo/ 7 | 8 | .env* 9 | !.env.example 10 | 11 | .DS_Store 12 | .vscode 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Khiet Tam Nguyen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 13 | 14 |
4 | 5 | 6 | 7 | 9 |

10 | RT Stack 11 |

12 |
15 | 16 | A modern & lightweight [turborepo](https://turbo.build/repo/docs) template for 17 | fullstack projects with modular components, shared configs, containerised 18 | deployments and 100% type-safety. 19 | 20 | - [About](#about) 21 | - [Stack overview](#stack-overview) 22 | - [Base Functionalities](#base-functionalities) 23 | - [Inspirations & Goals](#inspirations--goals) 24 | - [Quick Start](#quick-start) 25 | - [Prerequisites](#prerequisites) 26 | - [Setup](#setup) 27 | - [Using an External Database](#using-an-external-database) 28 | - [Developing](#developing) 29 | - [Working with a single package](#working-with-a-single-package) 30 | - [Adding new shadcn components](#adding-new-shadcn-components) 31 | - [Adding new better-auth plugins](#adding-new-better-auth-plugins) 32 | - [Tooling Scripts](#tooling-scripts) 33 | - [Containerisation (Docker/Podman)](#containerisation-dockerpodman) 34 | - [Deployment](#deployment) 35 | - [Using Containers](#using-containers) 36 | - [Using Major Platforms](#using-major-platforms) 37 | - [Other Notes](#other-notes) 38 | - [Tanstack Router](#tanstack-router) 39 | - [Server API Artificial Delays](#server-api-artificial-delays) 40 | - [Environment Variables](#environment-variables) 41 | - [Extensions to existing template](#extensions-to-existing-template) 42 | 43 | ## About 44 | 45 | ### Stack overview 46 | 47 | Below is an overview of all the components in the stack: 48 | 49 | ``` 50 | apps 51 | ├─ web 52 | | ├─ react (vite) 53 | | ├─ tanstack (router, query, form) 54 | | └─ tailwindcss 55 | ├─ server 56 | | └─ hono (wrapper for api & auth) 57 | packages 58 | ├─ api 59 | | └─ trpc with valibot 60 | ├─ auth 61 | | └─ better-auth 62 | ├─ db 63 | | └─ drizzle-orm (postgres database) 64 | ├─ ui 65 | | ├─ tailwindcss 66 | | └─ shadcn & radix ui 67 | tools 68 | ├─ eslint 69 | ├─ prettier 70 | ├─ tailwind 71 | └─ typescript 72 | ``` 73 | 74 | View all catalog dependencies in [pnpm-workspace.yaml](pnpm-workspace.yaml). 75 | 76 | ### Base Functionalities 77 | 78 | The following features are implemented out-of-the-box: 79 | 80 | - login/register (using [better-auth email/password](https://www.better-auth.com/docs/authentication/email-password)) credentials provider 81 | - themes (dark/light mode using [next-themes](github.com/pacocoursey/next-themes)) 82 | - web/server integration ([trpc](https://trpc.io/docs/quickstart) API example for creating/listing posts) 83 | 84 | You can visit the [live demo](https://rtstack.nktnet.uk) to see these features in action. 85 | 86 | ### Inspirations & Goals 87 | 88 | Many aspects of the RT Stack were derived from the 89 | [t3-oss/create-t3-turbo](https://github.com/t3-oss/create-t3-turbo). However, 90 | there is a preference for: 91 | 92 | - [tanstack router](https://tanstack.com/router/latest) (web) + [hono](https://hono.dev) (server) instead of [nextjs](https://nextjs.org) (fullstack) 93 | - [better auth](https://www.better-auth.com) for authentication instead [auth.js (next auth)](https://authjs.dev) 94 | - [valibot](https://valibot.dev) for input validation instead of [zod](https://zod.dev) 95 | - [tanstack form](https://tanstack.com/form/latest) instead of [react-hook-form](https://react-hook-form.com) 96 | - using `.env` in each application/package instead of globally, as per [turborepo's recommendations](https://turbo.build/repo/docs/crafting-your-repository/using-environment-variables#best-practices) 97 | 98 | This project also aims to consistently adopt the latest releases of dependencies and tools. For example: 99 | 100 | - react v19 101 | - tailwindcss v4 & shadcn-ui (canary) 102 | - trpc v11 103 | - eslint v9 104 | - pnpm v10 105 | 106 | ## Quick Start 107 | 108 | ### Prerequisites 109 | 110 | Ensure the following tools are available on your system: 111 | 112 | 1. [node](https://nodejs.org/en/download) (version 22+) 113 | 1. [pnpm](https://pnpm.io/installation) (version 10+) 114 | 1. [postgres](https://www.postgresql.org) database, which you can easily run using tools like: 115 | - [docker](https://docs.docker.com/engine/install) and [docker-compose](https://docs.docker.com/compose) 116 | - [podman](https://podman.io/docs/installation) and [podman-compose](https://github.com/containers/podman-compose) 117 | - [supabase](https://supabase.com)'s free tier cloud database 118 | 119 | ### Setup 120 | 121 | ```bash 122 | # Create a repository using the rt-stack template (replace YOUR_PROJECT) 123 | pnpm dlx create-turbo@latest -m pnpm -e https://github.com/nktnet1/rt-stack YOUR_PROJECT 124 | 125 | # Enter the directory or open in your IDE (replace YOUR_PROJECT) 126 | cd YOUR_PROJECT 127 | 128 | # Install all dependencies for apps and packages 129 | pnpm install 130 | 131 | # Copy .env.example to .env for all applications and the @repo/db package 132 | pnpm env:copy-example 133 | 134 | # Start a local postgres instance in the background (e.g. using docker) 135 | docker compose up db --detach 136 | 137 | # Push the drizzle schema to your database 138 | pnpm db:push 139 | ``` 140 | 141 | You can then start all applications with 142 | 143 | ```bash 144 | pnpm dev 145 | ``` 146 | 147 | By default the following URLs will be accessible: 148 | 149 | - web application: http://localhost:8085 150 | - backend server: http://localhost:3035 151 | 152 | ### Using an External Database 153 | 154 | When using an external postgres database (e.g. from [supabase](https://supabase.com)), you can skip the step that spins up a local postgres instance with docker. 155 | 156 | Instead, you will need to modify the following environment variables: 157 | 158 | 1. `SERVER_POSTGRES_URL` in the file `apps/server/.env` 159 | 160 | - used at runtime by the backend server in `pnpm dev` 161 | 162 | 1. `DB_POSTGRES_URL` in the file `packages/db/.env` 163 | - used in database schema migrations with `pnpm db:push` 164 | 165 | ## Developing 166 | 167 | ### Working with a single package 168 | 169 | Use [`pnpm --filter=`](https://pnpm.io/filtering) (where `` is 170 | defined in the `package.json` of each package). 171 | 172 | Example usage: 173 | 174 | ```bash 175 | # Install the nuqs package for our web application: 176 | pnpm --filter=web install nuqs 177 | 178 | # Format only the ui package: 179 | pnpm --filter=@repo/ui format 180 | ``` 181 | 182 | You can get a list of all package names using the command below: 183 | 184 | ```bash 185 | find . -maxdepth 3 -name "package.json" -exec grep '"name":' {} \; 186 | ``` 187 | 188 | ### Adding new shadcn components 189 | 190 | To install a single Shadcn/UI component, e.g. `button`, use the command 191 | 192 | ```bash 193 | pnpm ui-add button 194 | ``` 195 | 196 | You can also open an interactive session to select components using a TUI by not passing any arguments 197 | 198 | ```bash 199 | pnpm ui-add 200 | ``` 201 | 202 | - press `i` to enter interactive mode on startup 203 | - use `j/k` (or arrow keys) to navigate up and down. 204 | - use `` to toggle select your desired component(s) 205 | - hit `` to install all selected components 206 | 207 | ### Adding new better-auth plugins 208 | 209 | When integrating more better-auth plugins, e.g. 210 | 211 | - [admin](https://better-auth.vercel.app/docs/plugins/admin) 212 | - [organization](https://better-auth.vercel.app/docs/plugins/organization) 213 | 214 | You should 215 | 216 | 1. Modify the auth package server and client files in accordance with the plugin's 217 | respective documentations. 218 | 219 | 2. Run the interactive command: 220 | 221 | ```bash 222 | pnpm auth:schema:generate 223 | ``` 224 | 225 | Press `i` to enter interactive mode, then `y` to overwrite [packages/db/src/schemas/auth.ts](packages/db/src/schemas/auth.ts). 226 | 227 | 3. Format and fix all linting issues, e.g. with 228 | 229 | ```bash 230 | pnpm format:fix 231 | pnpm lint:fix 232 | ``` 233 | 234 | 4. Push your new schema to the database 235 | 236 | ```bash 237 | pnpm db:push 238 | ``` 239 | 240 | 5. Occasionally, the type inference will not work immediately in your IDE (e.g. in VSCode). 241 | This can be resolved by running 242 | 243 | ```bash 244 | pnpm clean && pnpm install 245 | ``` 246 | 247 | followed by a restarting your TS Server or reloading VSCode. 248 | 249 | You can find an example in the [better-auth-admin-organization-plugins](https://github.com/nktnet1/rt-stack/tree/better-auth-admin-organization-plugins) branch. 250 | 251 | ### Tooling Scripts 252 | 253 | All scripts are defined in [package.json](package.json) and 254 | [turbo.json](turbo.json): 255 | 256 | ```bash 257 | pnpm clean # remove all .cache, .turbo, dist, node_modules 258 | 259 | pnpm typecheck # report typescript issues 260 | 261 | pnpm format # report prettier issues 262 | pnpm format:fix # auto-fix prettier issues 263 | 264 | pnpm lint # report eslint issues 265 | pnpm lint:fix # auto-fix eslint issues 266 | 267 | pnpx codemod pnpm/catalog # migrate dependencies to pnpm-workspace.yaml 268 | ``` 269 | 270 | ## Containerisation (Docker/Podman) 271 | 272 | Both the `web` and `server` applications have been containerised. You can start 273 | see this in action by running the commands: 274 | 275 | ```bash 276 | # Start all applications 277 | docker compose up --build 278 | 279 | # Push the drizzle schema to your database. While you can use `pnpm db:push` on 280 | # the host machine if you have installed all the required dependencies, it is 281 | # also possible to do everything within docker alone. 282 | # Open a second terminal and run the command: 283 | docker compose run --build --rm drizzle 284 | 285 | # Upon completion, you will be inside the `drizzle` docker container instead 286 | # of the host machine. It is now possible to push the schema with: 287 | pnpm db:push 288 | ``` 289 | 290 | You can then open the web link below in your browser: 291 | 292 | - http://localhost:8085 293 | 294 | Please note that these containers are run in production mode. For further 295 | details, see 296 | 297 | - [compose.yaml](compose.yaml) 298 | - [apps/server/Dockerfile](apps/server/Dockerfile) 299 | - [apps/web/Dockerfile](apps/web/Dockerfile) 300 | - [apps/web/nginx.conf](apps/web/nginx.conf) 301 | 302 | ## Deployment 303 | 304 | > [!TIP] 305 | > The [live demo](https://rtstack.nktnet.uk) of RT Stack is currently deployed to 306 | > 307 | > - vercel for the web frontend 308 | > - fly.io for the server backend and postgres database 309 | 310 | ### Using Containers 311 | 312 | You can deploy applications to any services that supports docker deployment. 313 | 314 | Using docker compose (see [compose.yaml](compose.yaml)) is also an option, 315 | although this alone may not be production-ready at scale. However, it can be 316 | paired with 317 | 318 | - reverse proxies and load balancers offered by tools like 319 | [Traefik](https://github.com/traefik/traefik) or 320 | [Caddy](https://github.com/caddyserver/caddy) 321 | - container orchestration platforms like [Docker Swarm](https://docs.docker.com/engine/swarm) and [Kubernetes](https://kubernetes.io) 322 | 323 | Personally, I recommend setting up a Virtual Private Server (e.g. on [Hetzner](https://www.hetzner.com)) 324 | and make use of self-hostable PaaS software which automatically handles the complexity of deployment 325 | mentioned above for you - these includes: 326 | 327 | - Coolify 328 | - https://github.com/coollabsio/coolify 329 | - https://www.coolify.io 330 | - Dokploy 331 | - https://github.com/Dokploy/dokploy 332 | - http://dokploy.com 333 | 334 | Do note that for the **web** application, the `PUBLIC_SERVER_URL` variable 335 | available at build time (as a docker build argument), rather than an environment 336 | variable at runtime. 337 | 338 | Also, both the **server** application's `PUBLIC_WEB_URL` and the **web** 339 | application's `PUBLIC_SERVER_URL` needs to be set as internet-accessible URLs 340 | when deployed, e.g. `https://mycompany.com` and `https://api.mycompany.com`, 341 | rather than referencing `http://localhost:8085` like in development. 342 | 343 | ### Using Major Platforms 344 | 345 | The **web** application is a simple React static site powered by Vite, which is 346 | easily deployed to platforms such as GitHub/GitLab pages, Vercel and Netlify. 347 | You can refer to the [vite documentation](https://vite.dev/guide/static-deploy) 348 | for deployment guides on all major platforms. 349 | 350 | The **server** application uses the [hono](https://hono.dev) web framework with 351 | the [NodeJS runtime](https://hono.dev/docs/getting-started/nodejs). However, 352 | this can be exchanged with other runtimes before deploying to your chosen 353 | platforms. For example, deploying to Netlify is covered within 354 | [Hono's documentations](https://hono.dev/docs/getting-started/netlify#_4-deploy). 355 | 356 | Note that when deploying your web frontend and server backend to two different 357 | domains, you will need to [tweak your better-auth configurations](https://www.better-auth.com/docs/integrations/hono#cross-domain-cookies). 358 | Apple's Safari browser also does not support third party cookies, so auth will 359 | not function as expected without any proxy workarounds. 360 | 361 | To keep things simple, it is recommended that you host your frontend and 362 | backend on the same root domain and differ by subdomains. For example, the 363 | frontend can be served at either `example.com` or `web.example.com`, and the 364 | backend hosted at `api.example.com`. 365 | 366 | ## Other Notes 367 | 368 | ### Tanstack Router 369 | 370 | The following is configured in [vite.config.ts](apps/web/vite.config.ts) web 371 | application: 372 | 373 | ```ts 374 | TanStackRouterVite({ 375 | routeToken: 'layout', 376 | }), 377 | ``` 378 | 379 | This enables the use of a `layout.tsx` file in each directory similar to NextJS. 380 | You can read more about this 381 | [here](https://github.com/TanStack/router/discussions/1102#discussioncomment-10946603). 382 | 383 | Also, it is recommended that you exclude the `routerTree.gen.ts` from your IDE. 384 | For example, in VSCode, you can add the following `.vscode/settings.json` at the 385 | root of your turborepo: 386 | 387 | ```json 388 | { 389 | "files.readonlyInclude": { 390 | "**/routeTree.gen.ts": true 391 | }, 392 | "files.watcherExclude": { 393 | "**/routeTree.gen.ts": true 394 | }, 395 | "search.exclude": { 396 | "**/routeTree.gen.ts": true 397 | } 398 | } 399 | ``` 400 | 401 | ### Server API Artificial Delays 402 | 403 | There is an artificial delay added in development mode to simulate API usage in 404 | real-world environments. You can disable this by removing the `timingMiddleware` 405 | in [./packages/api/src/server/trpc.ts](./packages/api/src/server/trpc.ts) 406 | 407 | ### Environment Variables 408 | 409 | This template was made to follow the the recommendation of 410 | 411 | - @tyleralbee in [this turborepo's GitHub discussion](https://github.com/vercel/turborepo/discussions/9458#discussioncomment-11443969) 412 | - @cjkihl in [create-t3-turbo issue #397](https://github.com/t3-oss/create-t3-turbo/issues/397#issuecomment-1630028405) 413 | - turborepo official docs on [environment variables best practices](https://turbo.build/repo/docs/crafting-your-repository/using-environment-variables#best-practices) 414 | 415 | In using this template, it is recommended that 416 | 417 | 1. each application has a local `.env` file instead of a global `.env` at the 418 | root of your repository 419 | 1. packages should be pure, i.e. rely on factory methods and receiving inputs to 420 | instantiate rather than consuming environment variables directly 421 | - one exception is the `@repo/db` package, which requires the 422 | `DB_POSTGRES_URL` variable for schema migration with `pnpm db:push` 423 | 1. environment variables are prefixed, e.g. `SERVER_AUTH_SECRET` instead of 424 | `AUTH_SECRET`. Caching in the app's `turbo.json` can then be configured to 425 | use wildcards such as: 426 | ```json 427 | "tasks": { 428 | "build": { 429 | "env": ["SERVER_*"], 430 | } 431 | } 432 | ``` 433 | 434 | There is also a script that creates a `.env` from `.env.example` of each 435 | app/package, which can be run with: 436 | 437 | ```bash 438 | # NOTE: This will not overwrite existing local .env files 439 | pnpm env:copy-example 440 | 441 | # To reset any modifications to your .env and restore the examples, run: 442 | pnpm env:remove 443 | pnpm env:copy-example 444 | ``` 445 | 446 | It is recommended that any new apps that uses environment variables follow the 447 | example script set in [apps/server/package.json](apps/server/package.json). 448 | 449 |

Extensions to Existing Template

450 |

The table below demonstrates how you can build and extend upon the existing RT Stack template:

451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 |
FeatureDescriptionBranch Link
NextJSAdds a docs application that uses NextJS and Fumadocs, along with the workspace @repo/ui package.nextjs-fumadocs
Multi-language supportImplements internationalisation support, e.g. switching between English and Vietnamese.i18n
Better-auth PluginsDemonstrates how to integrate better-auth plugins in a type-safe and CLI-compatible manner.better-auth-admin-organization-plugins
478 | -------------------------------------------------------------------------------- /apps/server/.env.example: -------------------------------------------------------------------------------- 1 | SERVER_AUTH_SECRET=please_change_this_in_production 2 | SERVER_POSTGRES_URL=postgres://postgres:postgres@localhost:5432/postgres 3 | SERVER_HOST=localhost 4 | SERVER_PORT=3035 5 | 6 | # Frontend URL, used to configure trusted origin (cors) 7 | PUBLIC_WEB_URL=http://localhost:8085 8 | -------------------------------------------------------------------------------- /apps/server/.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .turbo/ 3 | .DS_Store 4 | 5 | .env* 6 | !.env.example 7 | 8 | dist/ 9 | node_modules/ -------------------------------------------------------------------------------- /apps/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | ENV NODE_ENV=production 4 | 5 | WORKDIR /app 6 | 7 | # =========================================================================== # 8 | 9 | FROM base AS builder-base 10 | 11 | ENV TURBO_TELEMETRY_DISABLED=1 12 | ENV PNPM_HOME="/pnpm" 13 | ENV PATH="$PNPM_HOME:$PATH" 14 | ENV CI=1 15 | 16 | RUN corepack enable pnpm 17 | 18 | # =========================================================================== # 19 | 20 | FROM builder-base AS builder 21 | 22 | RUN pnpm install --global turbo@^2 23 | 24 | COPY . . 25 | 26 | # https://turbo.build/repo/docs/guides/tools/docker#the-solution 27 | RUN turbo prune server --docker 28 | 29 | # =========================================================================== # 30 | 31 | FROM builder-base AS installer 32 | 33 | COPY --from=builder /app/out/json/ . 34 | RUN pnpm install --frozen-lockfile 35 | 36 | COPY --from=builder /app/out/full/ . 37 | RUN pnpm build 38 | 39 | # =========================================================================== # 40 | 41 | FROM base AS production 42 | 43 | RUN addgroup --system --gid 1001 nodejs \ 44 | && adduser --system --uid 1001 hono 45 | 46 | COPY --from=installer --chown=hono:nodejs /app/apps/server/dist /app/dist 47 | 48 | USER hono 49 | 50 | HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ 51 | CMD wget --quiet --spider http://${SERVER_HOST}:${SERVER_PORT}/healthcheck || exit 1 52 | 53 | CMD ["node", "/app/dist/index.js"] 54 | -------------------------------------------------------------------------------- /apps/server/eslint.config.js: -------------------------------------------------------------------------------- 1 | import baseConfig from '@repo/eslint-config/base'; 2 | 3 | /** @type {import('typescript-eslint').Config} */ 4 | export default [...baseConfig]; 5 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "type": "module", 4 | "scripts": { 5 | "build": "tsup", 6 | "clean": "git clean -xdf .cache .turbo dist node_modules", 7 | "dev": "pnpm env:run tsx watch src/index.ts", 8 | "env:copy-example": "shx -- cp -n .env.example .env", 9 | "env:remove": "shx -- rm -f .env", 10 | "env:run": "dotenv --", 11 | "format": "prettier --check . --ignore-path ../../.gitignore", 12 | "lint": "eslint", 13 | "start": "NODE_ENV=production pnpm env:run node dist/index.js", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "prettier": "@repo/prettier-config", 17 | "dependencies": { 18 | "@hono/node-server": "catalog:", 19 | "@hono/trpc-server": "catalog:", 20 | "@repo/api": "workspace:*", 21 | "@repo/auth": "workspace:*", 22 | "@repo/db": "workspace:*", 23 | "hono": "catalog:", 24 | "valibot": "catalog:" 25 | }, 26 | "devDependencies": { 27 | "@repo/eslint-config": "workspace:*", 28 | "@repo/prettier-config": "workspace:*", 29 | "@repo/typescript-config": "workspace:*", 30 | "@types/node": "catalog:", 31 | "dotenv-cli": "catalog:", 32 | "eslint": "catalog:", 33 | "shx": "catalog:", 34 | "tsup": "catalog:", 35 | "tsx": "catalog:", 36 | "typescript": "catalog:" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/server/src/env.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | const DEFAULT_SERVER_PORT = 3035; 4 | const DEFAULT_SERVER_HOST = 'localhost'; 5 | 6 | const createPortSchema = ({ defaultPort }: { defaultPort: number }) => 7 | v.pipe( 8 | v.optional(v.string(), `${defaultPort}`), 9 | v.transform((s) => parseInt(s, 10)), 10 | v.integer(), 11 | v.minValue(0), 12 | v.maxValue(65535), 13 | ); 14 | 15 | export const envSchema = v.object({ 16 | SERVER_PORT: createPortSchema({ defaultPort: DEFAULT_SERVER_PORT }), 17 | SERVER_HOST: v.pipe( 18 | v.optional(v.string(), DEFAULT_SERVER_HOST), 19 | v.minLength(1), 20 | ), 21 | SERVER_AUTH_SECRET: v.pipe(v.string(), v.minLength(1)), 22 | SERVER_POSTGRES_URL: v.string(), 23 | 24 | // Frontend URL, used to configure trusted origin (CORS) 25 | PUBLIC_WEB_URL: v.pipe(v.string(), v.url()), 26 | }); 27 | 28 | export const env = v.parse(envSchema, process.env); 29 | -------------------------------------------------------------------------------- /apps/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from '@hono/node-server'; 2 | import { trpcServer } from '@hono/trpc-server'; 3 | import { createApi } from '@repo/api/server'; 4 | import { createAuth } from '@repo/auth/server'; 5 | import { createDb } from '@repo/db/client'; 6 | import { Hono } from 'hono'; 7 | import { cors } from 'hono/cors'; 8 | import { logger } from 'hono/logger'; 9 | import { env } from './env'; 10 | 11 | const trustedOrigins = [env.PUBLIC_WEB_URL].map((url) => new URL(url).origin); 12 | 13 | const wildcardPath = { 14 | ALL: '*', 15 | BETTER_AUTH: '/api/auth/*', 16 | TRPC: '/trpc/*', 17 | } as const; 18 | 19 | const db = createDb({ databaseUrl: env.SERVER_POSTGRES_URL }); 20 | const auth = createAuth({ 21 | authSecret: env.SERVER_AUTH_SECRET, 22 | db, 23 | webUrl: env.PUBLIC_WEB_URL, 24 | }); 25 | const api = createApi({ auth, db }); 26 | 27 | const app = new Hono<{ 28 | Variables: { 29 | user: typeof auth.$Infer.Session.user | null; 30 | session: typeof auth.$Infer.Session.session | null; 31 | }; 32 | }>(); 33 | 34 | app.get('/healthcheck', (c) => { 35 | return c.text('OK'); 36 | }); 37 | 38 | app.use(logger()); 39 | 40 | app.use( 41 | wildcardPath.BETTER_AUTH, 42 | cors({ 43 | origin: trustedOrigins, 44 | credentials: true, 45 | allowHeaders: ['Content-Type', 'Authorization'], 46 | allowMethods: ['POST', 'GET', 'OPTIONS'], 47 | exposeHeaders: ['Content-Length'], 48 | maxAge: 600, 49 | }), 50 | ); 51 | 52 | app.use( 53 | wildcardPath.TRPC, 54 | cors({ 55 | origin: trustedOrigins, 56 | credentials: true, 57 | }), 58 | ); 59 | 60 | app.on(['POST', 'GET'], wildcardPath.BETTER_AUTH, (c) => 61 | auth.handler(c.req.raw), 62 | ); 63 | 64 | app.use( 65 | wildcardPath.TRPC, 66 | trpcServer({ 67 | router: api.trpcRouter, 68 | createContext: (c) => api.createTRPCContext({ headers: c.req.headers }), 69 | }), 70 | ); 71 | 72 | app.get('/', (c) => { 73 | return c.text('Hello Hono!'); 74 | }); 75 | 76 | const server = serve( 77 | { 78 | fetch: app.fetch, 79 | port: env.SERVER_PORT, 80 | hostname: env.SERVER_HOST, 81 | }, 82 | (info) => { 83 | const host = info.family === 'IPv6' ? `[${info.address}]` : info.address; 84 | console.log(`Hono internal server: http://${host}:${info.port}`); 85 | }, 86 | ); 87 | 88 | const shutdown = () => { 89 | server.close((error) => { 90 | if (error) { 91 | console.error(error); 92 | } else { 93 | console.log('\nServer has stopped gracefully.'); 94 | } 95 | process.exit(0); 96 | }); 97 | }; 98 | 99 | process.on('SIGINT', shutdown); 100 | process.on('SIGTERM', shutdown); 101 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/base.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "types": ["node"], 6 | "jsxImportSource": "hono/jsx" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/server/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['./src/index.ts'], 5 | format: 'esm', 6 | noExternal: [/.*/], 7 | platform: 'node', 8 | splitting: false, 9 | bundle: true, 10 | outDir: './dist', 11 | clean: true, 12 | env: { IS_SERVER_BUILD: 'true' }, 13 | loader: { '.json': 'copy' }, 14 | minify: false, 15 | sourcemap: true, 16 | 17 | // https://github.com/egoist/tsup/issues/927#issuecomment-2416440833 18 | banner: ({ format }) => { 19 | if (format === 'esm') 20 | return { 21 | js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`, 22 | }; 23 | return {}; 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /apps/server/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "extends": ["//"], 4 | "tasks": { 5 | "dev": { 6 | "env": ["SERVER_*", "PUBLIC_WEB_URL"], 7 | "inputs": ["$TURBO_DEFAULT$", ".env"], 8 | "persistent": true 9 | }, 10 | "build": { 11 | "env": ["SERVER_*", "PUBLIC_WEB_URL"], 12 | "inputs": ["$TURBO_DEFAULT$", ".env"] 13 | }, 14 | "start": { 15 | "env": ["SERVER_*", "PUBLIC_WEB_URL"], 16 | "persistent": true 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | # Used for web application - this is the backend API server. 2 | # This should be passed as a build-time variable (ARG) in docker. 3 | PUBLIC_SERVER_URL=http://localhost:3035 4 | 5 | # Used only to set default port/host in development (vite.config.ts) 6 | PUBLIC_WEB_URL=http://localhost:8085 7 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .turbo/ 3 | .DS_Store 4 | 5 | .env* 6 | !.env.example 7 | 8 | dist/ 9 | node_modules/ -------------------------------------------------------------------------------- /apps/web/.prettierignore: -------------------------------------------------------------------------------- 1 | **/src/routeTree.gen.ts 2 | -------------------------------------------------------------------------------- /apps/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | WORKDIR /app 4 | 5 | ENV NODE_ENV=production 6 | ENV TURBO_TELEMETRY_DISABLED=1 7 | ENV PNPM_HOME="/pnpm" 8 | ENV PATH="$PNPM_HOME:$PATH" 9 | ENV CI=1 10 | 11 | RUN corepack enable pnpm 12 | 13 | # =========================================================================== # 14 | 15 | FROM base AS builder 16 | 17 | RUN pnpm install --global turbo@^2 18 | 19 | COPY . . 20 | 21 | # https://turbo.build/repo/docs/guides/tools/docker#the-solution 22 | RUN turbo prune web --docker 23 | 24 | # =========================================================================== # 25 | 26 | FROM base AS installer 27 | 28 | ARG PUBLIC_SERVER_URL 29 | ENV PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL} 30 | 31 | COPY --from=builder /app/out/json/ . 32 | RUN pnpm install --frozen-lockfile 33 | 34 | COPY --from=builder /app/out/full/ . 35 | RUN pnpm build 36 | 37 | # =========================================================================== # 38 | 39 | FROM nginx:stable-alpine AS production 40 | 41 | WORKDIR /app 42 | 43 | COPY apps/web/nginx.conf /etc/nginx/nginx.conf 44 | COPY --from=installer /app/apps/web/dist /usr/share/nginx/html 45 | 46 | HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ 47 | CMD curl --fail --silent http://0.0.0.0:80/healthcheck || exit 1 48 | 49 | CMD ["nginx", "-g", "daemon off;"] 50 | -------------------------------------------------------------------------------- /apps/web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { restrictEnvAccess } from '@repo/eslint-config/base'; 2 | import reactConfig from '@repo/eslint-config/react'; 3 | 4 | /** @type {import("eslint").Linter.Config} */ 5 | export default [ 6 | ...reactConfig, 7 | ...restrictEnvAccess, 8 | { 9 | files: ['vite.config.ts'], 10 | rules: { 11 | 'no-restricted-properties': 'off', 12 | }, 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 45 | RT Stack 46 | 47 | 48 | 49 |
50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /apps/web/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 4; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | include mime.types; 9 | 10 | default_type application/octet-stream; 11 | 12 | set_real_ip_from 0.0.0.0/0; 13 | real_ip_recursive on; 14 | real_ip_header X-Forward-For; 15 | limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s; 16 | 17 | access_log /dev/stdout; 18 | error_log /dev/stderr; 19 | 20 | server { 21 | listen 80; 22 | 23 | location / { 24 | root /usr/share/nginx/html; 25 | index index.html index.htm; 26 | try_files $uri $uri/ /index.html; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "pnpm env:run build", 8 | "clean": "git clean -xdf .cache dist .turbo node_modules", 9 | "dev": "pnpm env:run dev", 10 | "env:copy-example": "shx -- cp -n .env.example .env", 11 | "env:remove": "shx -- rm -f .env", 12 | "env:run": "dotenv -- vite --configLoader runner", 13 | "format": "prettier --check . --ignore-path .prettierignore --ignore-path ../../.gitignore", 14 | "lint": "eslint \"src/**/*.{ts,tsx}\"", 15 | "preview": "pnpm env:run preview", 16 | "start": "pnpm env:run", 17 | "typecheck": "tsc --build" 18 | }, 19 | "dependencies": { 20 | "@radix-ui/react-icons": "catalog:", 21 | "@repo/auth": "workspace:*", 22 | "@repo/ui": "workspace:*", 23 | "@tailwindcss/vite": "catalog:", 24 | "@tanstack/react-form": "catalog:", 25 | "@tanstack/react-query": "catalog:", 26 | "@tanstack/react-router": "catalog:", 27 | "@tanstack/router-devtools": "catalog:", 28 | "@trpc/client": "catalog:", 29 | "@trpc/tanstack-react-query": "catalog:", 30 | "dotenv-cli": "catalog:", 31 | "next-themes": "catalog:", 32 | "react": "catalog:", 33 | "react-dom": "catalog:", 34 | "sonner": "catalog:", 35 | "tailwindcss": "catalog:", 36 | "valibot": "catalog:" 37 | }, 38 | "prettier": "@repo/prettier-config", 39 | "devDependencies": { 40 | "@repo/api": "workspace:*", 41 | "@repo/eslint-config": "workspace:*", 42 | "@repo/prettier-config": "workspace:*", 43 | "@repo/tailwind-config": "workspace:*", 44 | "@repo/typescript-config": "workspace:*", 45 | "@tanstack/router-plugin": "catalog:", 46 | "@trpc/server": "catalog:", 47 | "@types/node": "catalog:", 48 | "@types/react": "catalog:", 49 | "@types/react-dom": "catalog:", 50 | "@vitejs/plugin-react-swc": "catalog:", 51 | "eslint": "catalog:", 52 | "shx": "catalog:", 53 | "typescript": "catalog:", 54 | "vite": "catalog:" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nktnet1/rt-stack/d51722d969cf596a4b51dcff15ff93bd3c002a54/apps/web/public/favicon.png -------------------------------------------------------------------------------- /apps/web/public/healthcheck: -------------------------------------------------------------------------------- 1 | OK 2 | -------------------------------------------------------------------------------- /apps/web/src/clients/authClient.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from '@repo/auth/client'; 2 | import { env } from '@/env'; 3 | 4 | export const authClient = createAuthClient({ 5 | apiBaseUrl: env.PUBLIC_SERVER_URL, 6 | }); 7 | 8 | export type AuthSession = 9 | | ReturnType['$Infer']['Session'] 10 | | null; 11 | -------------------------------------------------------------------------------- /apps/web/src/clients/queryClient.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | export const queryClient = new QueryClient(); 4 | -------------------------------------------------------------------------------- /apps/web/src/clients/trpcClient.ts: -------------------------------------------------------------------------------- 1 | import { createTrpcClient } from '@repo/api/client'; 2 | import { env } from '@/env'; 3 | 4 | export const trpcClient = createTrpcClient({ 5 | serverUrl: env.PUBLIC_SERVER_URL, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/web/src/env.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | export const CLIENT_ENV_PREFIX = 'PUBLIC_'; 4 | 5 | export const envSchema = v.object({ 6 | /** 7 | * This is the backend API server. Note that this should be passed as 8 | * a build-time variable (ARG) in docker. 9 | */ 10 | PUBLIC_SERVER_URL: v.pipe(v.string(), v.url()), 11 | 12 | /** 13 | * Set this if you want to run or deploy your app at a base URL. This is 14 | * usually required for deploying a repository to Github/Gitlab pages. 15 | */ 16 | PUBLIC_BASE_PATH: v.pipe(v.optional(v.string(), '/'), v.startsWith('/')), 17 | }); 18 | 19 | export const env = v.parse(envSchema, import.meta.env); 20 | -------------------------------------------------------------------------------- /apps/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@/style.css'; 2 | import { RouterProvider } from '@tanstack/react-router'; 3 | import { ThemeProvider } from 'next-themes'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom/client'; 6 | import { createRouter } from '@/router'; 7 | 8 | const ROOT_ELEMENT_ID = 'app'; 9 | 10 | const rootElement = document.getElementById(ROOT_ELEMENT_ID); 11 | 12 | if (!rootElement) { 13 | throw new Error(`Root element with ID '${ROOT_ELEMENT_ID}' not found.`); 14 | } 15 | 16 | const router = createRouter(); 17 | 18 | if (!rootElement.innerHTML) { 19 | const root = ReactDOM.createRoot(rootElement); 20 | root.render( 21 | 22 | 29 | 30 | 31 | , 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | // Import Routes 12 | 13 | import { Route as rootRoute } from './routes/__root' 14 | import { Route as PublicLayoutImport } from './routes/_public/layout' 15 | import { Route as ProtectedLayoutImport } from './routes/_protected/layout' 16 | import { Route as IndexImport } from './routes/index' 17 | import { Route as PublicRegisterImport } from './routes/_public/register' 18 | import { Route as PublicLoginImport } from './routes/_public/login' 19 | import { Route as ProtectedPostsIndexImport } from './routes/_protected/posts/index' 20 | import { Route as ProtectedPostsPostidIndexImport } from './routes/_protected/posts/$postid/index' 21 | 22 | // Create/Update Routes 23 | 24 | const PublicLayoutRoute = PublicLayoutImport.update({ 25 | id: '/_public', 26 | getParentRoute: () => rootRoute, 27 | } as any) 28 | 29 | const ProtectedLayoutRoute = ProtectedLayoutImport.update({ 30 | id: '/_protected', 31 | getParentRoute: () => rootRoute, 32 | } as any) 33 | 34 | const IndexRoute = IndexImport.update({ 35 | id: '/', 36 | path: '/', 37 | getParentRoute: () => rootRoute, 38 | } as any) 39 | 40 | const PublicRegisterRoute = PublicRegisterImport.update({ 41 | id: '/register', 42 | path: '/register', 43 | getParentRoute: () => PublicLayoutRoute, 44 | } as any) 45 | 46 | const PublicLoginRoute = PublicLoginImport.update({ 47 | id: '/login', 48 | path: '/login', 49 | getParentRoute: () => PublicLayoutRoute, 50 | } as any) 51 | 52 | const ProtectedPostsIndexRoute = ProtectedPostsIndexImport.update({ 53 | id: '/posts/', 54 | path: '/posts/', 55 | getParentRoute: () => ProtectedLayoutRoute, 56 | } as any) 57 | 58 | const ProtectedPostsPostidIndexRoute = ProtectedPostsPostidIndexImport.update({ 59 | id: '/posts/$postid/', 60 | path: '/posts/$postid/', 61 | getParentRoute: () => ProtectedLayoutRoute, 62 | } as any) 63 | 64 | // Populate the FileRoutesByPath interface 65 | 66 | declare module '@tanstack/react-router' { 67 | interface FileRoutesByPath { 68 | '/': { 69 | id: '/' 70 | path: '/' 71 | fullPath: '/' 72 | preLoaderRoute: typeof IndexImport 73 | parentRoute: typeof rootRoute 74 | } 75 | '/_protected': { 76 | id: '/_protected' 77 | path: '' 78 | fullPath: '' 79 | preLoaderRoute: typeof ProtectedLayoutImport 80 | parentRoute: typeof rootRoute 81 | } 82 | '/_public': { 83 | id: '/_public' 84 | path: '' 85 | fullPath: '' 86 | preLoaderRoute: typeof PublicLayoutImport 87 | parentRoute: typeof rootRoute 88 | } 89 | '/_public/login': { 90 | id: '/_public/login' 91 | path: '/login' 92 | fullPath: '/login' 93 | preLoaderRoute: typeof PublicLoginImport 94 | parentRoute: typeof PublicLayoutImport 95 | } 96 | '/_public/register': { 97 | id: '/_public/register' 98 | path: '/register' 99 | fullPath: '/register' 100 | preLoaderRoute: typeof PublicRegisterImport 101 | parentRoute: typeof PublicLayoutImport 102 | } 103 | '/_protected/posts/': { 104 | id: '/_protected/posts/' 105 | path: '/posts' 106 | fullPath: '/posts' 107 | preLoaderRoute: typeof ProtectedPostsIndexImport 108 | parentRoute: typeof ProtectedLayoutImport 109 | } 110 | '/_protected/posts/$postid/': { 111 | id: '/_protected/posts/$postid/' 112 | path: '/posts/$postid' 113 | fullPath: '/posts/$postid' 114 | preLoaderRoute: typeof ProtectedPostsPostidIndexImport 115 | parentRoute: typeof ProtectedLayoutImport 116 | } 117 | } 118 | } 119 | 120 | // Create and export the route tree 121 | 122 | interface ProtectedLayoutRouteChildren { 123 | ProtectedPostsIndexRoute: typeof ProtectedPostsIndexRoute 124 | ProtectedPostsPostidIndexRoute: typeof ProtectedPostsPostidIndexRoute 125 | } 126 | 127 | const ProtectedLayoutRouteChildren: ProtectedLayoutRouteChildren = { 128 | ProtectedPostsIndexRoute: ProtectedPostsIndexRoute, 129 | ProtectedPostsPostidIndexRoute: ProtectedPostsPostidIndexRoute, 130 | } 131 | 132 | const ProtectedLayoutRouteWithChildren = ProtectedLayoutRoute._addFileChildren( 133 | ProtectedLayoutRouteChildren, 134 | ) 135 | 136 | interface PublicLayoutRouteChildren { 137 | PublicLoginRoute: typeof PublicLoginRoute 138 | PublicRegisterRoute: typeof PublicRegisterRoute 139 | } 140 | 141 | const PublicLayoutRouteChildren: PublicLayoutRouteChildren = { 142 | PublicLoginRoute: PublicLoginRoute, 143 | PublicRegisterRoute: PublicRegisterRoute, 144 | } 145 | 146 | const PublicLayoutRouteWithChildren = PublicLayoutRoute._addFileChildren( 147 | PublicLayoutRouteChildren, 148 | ) 149 | 150 | export interface FileRoutesByFullPath { 151 | '/': typeof IndexRoute 152 | '': typeof PublicLayoutRouteWithChildren 153 | '/login': typeof PublicLoginRoute 154 | '/register': typeof PublicRegisterRoute 155 | '/posts': typeof ProtectedPostsIndexRoute 156 | '/posts/$postid': typeof ProtectedPostsPostidIndexRoute 157 | } 158 | 159 | export interface FileRoutesByTo { 160 | '/': typeof IndexRoute 161 | '': typeof PublicLayoutRouteWithChildren 162 | '/login': typeof PublicLoginRoute 163 | '/register': typeof PublicRegisterRoute 164 | '/posts': typeof ProtectedPostsIndexRoute 165 | '/posts/$postid': typeof ProtectedPostsPostidIndexRoute 166 | } 167 | 168 | export interface FileRoutesById { 169 | __root__: typeof rootRoute 170 | '/': typeof IndexRoute 171 | '/_protected': typeof ProtectedLayoutRouteWithChildren 172 | '/_public': typeof PublicLayoutRouteWithChildren 173 | '/_public/login': typeof PublicLoginRoute 174 | '/_public/register': typeof PublicRegisterRoute 175 | '/_protected/posts/': typeof ProtectedPostsIndexRoute 176 | '/_protected/posts/$postid/': typeof ProtectedPostsPostidIndexRoute 177 | } 178 | 179 | export interface FileRouteTypes { 180 | fileRoutesByFullPath: FileRoutesByFullPath 181 | fullPaths: '/' | '' | '/login' | '/register' | '/posts' | '/posts/$postid' 182 | fileRoutesByTo: FileRoutesByTo 183 | to: '/' | '' | '/login' | '/register' | '/posts' | '/posts/$postid' 184 | id: 185 | | '__root__' 186 | | '/' 187 | | '/_protected' 188 | | '/_public' 189 | | '/_public/login' 190 | | '/_public/register' 191 | | '/_protected/posts/' 192 | | '/_protected/posts/$postid/' 193 | fileRoutesById: FileRoutesById 194 | } 195 | 196 | export interface RootRouteChildren { 197 | IndexRoute: typeof IndexRoute 198 | ProtectedLayoutRoute: typeof ProtectedLayoutRouteWithChildren 199 | PublicLayoutRoute: typeof PublicLayoutRouteWithChildren 200 | } 201 | 202 | const rootRouteChildren: RootRouteChildren = { 203 | IndexRoute: IndexRoute, 204 | ProtectedLayoutRoute: ProtectedLayoutRouteWithChildren, 205 | PublicLayoutRoute: PublicLayoutRouteWithChildren, 206 | } 207 | 208 | export const routeTree = rootRoute 209 | ._addFileChildren(rootRouteChildren) 210 | ._addFileTypes() 211 | 212 | /* ROUTE_MANIFEST_START 213 | { 214 | "routes": { 215 | "__root__": { 216 | "filePath": "__root.tsx", 217 | "children": [ 218 | "/", 219 | "/_protected", 220 | "/_public" 221 | ] 222 | }, 223 | "/": { 224 | "filePath": "index.tsx" 225 | }, 226 | "/_protected": { 227 | "filePath": "_protected/layout.tsx", 228 | "children": [ 229 | "/_protected/posts/", 230 | "/_protected/posts/$postid/" 231 | ] 232 | }, 233 | "/_public": { 234 | "filePath": "_public/layout.tsx", 235 | "children": [ 236 | "/_public/login", 237 | "/_public/register" 238 | ] 239 | }, 240 | "/_public/login": { 241 | "filePath": "_public/login.tsx", 242 | "parent": "/_public" 243 | }, 244 | "/_public/register": { 245 | "filePath": "_public/register.tsx", 246 | "parent": "/_public" 247 | }, 248 | "/_protected/posts/": { 249 | "filePath": "_protected/posts/index.tsx", 250 | "parent": "/_protected" 251 | }, 252 | "/_protected/posts/$postid/": { 253 | "filePath": "_protected/posts/$postid/index.tsx", 254 | "parent": "/_protected" 255 | } 256 | } 257 | } 258 | ROUTE_MANIFEST_END */ 259 | -------------------------------------------------------------------------------- /apps/web/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from '@tanstack/react-query'; 2 | import { createRouter as createTanstackRouter } from '@tanstack/react-router'; 3 | import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'; 4 | import type { AppRouter } from '@repo/api/server'; 5 | import { queryClient } from '@/clients/queryClient'; 6 | import { trpcClient } from '@/clients/trpcClient'; 7 | import { env } from '@/env'; 8 | import { routeTree } from '@/routeTree.gen'; 9 | import Spinner from '@/routes/-components/common/spinner'; 10 | 11 | export const trpc = createTRPCOptionsProxy({ 12 | client: trpcClient, 13 | queryClient, 14 | }); 15 | 16 | export function createRouter() { 17 | const router = createTanstackRouter({ 18 | routeTree, 19 | basepath: env.PUBLIC_BASE_PATH, 20 | scrollRestoration: true, 21 | defaultPreload: 'intent', 22 | defaultPendingComponent: () => , 23 | Wrap: function WrapComponent({ children }) { 24 | return ( 25 | 26 | {children} 27 | 28 | ); 29 | }, 30 | }); 31 | return router; 32 | } 33 | 34 | declare module '@tanstack/react-router' { 35 | interface Register { 36 | router: ReturnType; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/routes/-components/common/form-field-info.tsx: -------------------------------------------------------------------------------- 1 | import type { AnyFieldApi } from '@tanstack/react-form'; 2 | 3 | export default function FormFieldInfo({ field }: { field: AnyFieldApi }) { 4 | return ( 5 |
6 | {field.state.meta.isTouched && field.state.meta.errors.length ? ( 7 | 8 | {field.state.meta.errors.map((e) => e.message).join(', ')} 9 | 10 | ) : null} 11 | {field.state.meta.isValidating ? 'Validating...' : null} 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/src/routes/-components/common/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { GearIcon } from '@radix-ui/react-icons'; 2 | import { cn } from '@repo/ui/lib/utils'; 3 | 4 | function Spinner({ className }: Readonly<{ className?: string }>) { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | 12 | export default Spinner; 13 | -------------------------------------------------------------------------------- /apps/web/src/routes/-components/layout/nav/nav-container.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | export default function NavContainer({ 4 | children, 5 | }: Readonly<{ 6 | children?: ReactNode; 7 | }>) { 8 | return ( 9 |
10 | {children} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/routes/-components/layout/nav/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@tanstack/react-router'; 2 | import type { AuthSession } from '@/clients/authClient'; 3 | import NavContainer from '@/routes/-components/layout/nav/nav-container'; 4 | import UserAvatar from '@/routes/-components/layout/nav/user-avatar'; 5 | import { postsLinkOptions } from '@/routes/_protected/posts/-validations/posts-link-options'; 6 | 7 | const activeClassName = 'underline decoration-2 opacity-70'; 8 | 9 | export function Navbar({ session }: Readonly<{ session: AuthSession }>) { 10 | return ( 11 | 12 |
13 | 18 | Home 19 | 20 | {session?.user ? ( 21 | 25 | Posts 26 | 27 | ) : null} 28 |
29 | {session?.user ? ( 30 | 31 | ) : ( 32 |
33 | 38 | Login 39 | 40 | | 41 | 46 | Register 47 | 48 |
49 | )} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /apps/web/src/routes/-components/layout/nav/user-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { ExitIcon, MoonIcon, SunIcon } from '@radix-ui/react-icons'; 2 | import { 3 | Avatar, 4 | AvatarFallback, 5 | AvatarImage, 6 | } from '@repo/ui/components/avatar'; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from '@repo/ui/components/dropdown-menu'; 13 | import { useTheme } from 'next-themes'; 14 | import { authClient } from '@/clients/authClient'; 15 | 16 | export default function UserAvatar({ 17 | user, 18 | }: Readonly<{ 19 | user: typeof authClient.$Infer.Session.user; 20 | }>) { 21 | const { resolvedTheme, setTheme } = useTheme(); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | {(user.name?.split(' ')[0]?.[0] || '') + 30 | (user.name?.split(' ')[1]?.[0] || '')} 31 | 32 | 33 | 34 | 35 |
36 | {user.name} 37 | {user.email} 38 |
39 | 40 |
41 | { 44 | e.preventDefault(); 45 | setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); 46 | }} 47 | > 48 | {resolvedTheme === 'dark' ? : } 49 | Theme 50 | 51 | { 53 | await authClient.signOut(); 54 | }} 55 | className="cursor-pointer" 56 | > 57 | 58 | Logout 59 | 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /apps/web/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from '@repo/ui/components/sonner'; 2 | import { Outlet, createRootRoute } from '@tanstack/react-router'; 3 | import React from 'react'; 4 | import { authClient } from '@/clients/authClient'; 5 | import Spinner from '@/routes/-components/common/spinner'; 6 | import NavContainer from '@/routes/-components/layout/nav/nav-container'; 7 | import { Navbar } from '@/routes/-components/layout/nav/navbar'; 8 | 9 | export const Route = createRootRoute({ 10 | component: RootComponent, 11 | }); 12 | 13 | // https://tanstack.com/router/v1/docs/framework/react/devtools 14 | const TanStackRouterDevtools = import.meta.env.PROD 15 | ? () => null 16 | : React.lazy(() => 17 | import('@tanstack/router-devtools').then((res) => ({ 18 | default: res.TanStackRouterDevtools, 19 | })), 20 | ); 21 | 22 | function RootComponent() { 23 | const { data: session, isPending } = authClient.useSession(); 24 | 25 | if (isPending) { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | return ( 34 | <> 35 | 36 | 37 |
38 | 39 |
40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /apps/web/src/routes/_protected/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Outlet, createFileRoute } from '@tanstack/react-router'; 2 | import { authClient } from '@/clients/authClient'; 3 | import Spinner from '@/routes/-components/common/spinner'; 4 | 5 | export const Route = createFileRoute('/_protected')({ 6 | component: Layout, 7 | }); 8 | 9 | function Layout() { 10 | const { data: session, isPending } = authClient.useSession(); 11 | 12 | if (isPending) { 13 | return ; 14 | } 15 | 16 | if (!session?.user) { 17 | return ; 18 | } 19 | 20 | return ; 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/src/routes/_protected/posts/$postid/index.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeftIcon, ReloadIcon } from '@radix-ui/react-icons'; 2 | import { Button } from '@repo/ui/components/button'; 3 | import { 4 | TooltipProvider, 5 | Tooltip, 6 | TooltipTrigger, 7 | TooltipContent, 8 | TooltipArrow, 9 | } from '@repo/ui/components/tooltip'; 10 | import { createFileRoute, Link } from '@tanstack/react-router'; 11 | import { queryClient } from '@/clients/queryClient'; 12 | import { trpc } from '@/router'; 13 | import { postsLinkOptions } from '@/routes/_protected/posts/-validations/posts-link-options'; 14 | 15 | export const Route = createFileRoute('/_protected/posts/$postid/')({ 16 | loader: ({ params }) => 17 | queryClient.ensureQueryData( 18 | trpc.posts.one.queryOptions({ id: params.postid }), 19 | ), 20 | component: RouteComponent, 21 | errorComponent: ({ error, reset }) => { 22 | return ( 23 |
24 |
{error.message}
25 |
26 | 32 | 42 |
43 |
44 | ); 45 | }, 46 | }); 47 | 48 | function RouteComponent() { 49 | const post = Route.useLoaderData(); 50 | 51 | return ( 52 |
53 |
54 |

{post.title}

55 |

56 | Created by {post.author.name},{' '} 57 | {post.createdAt.toLocaleString()} 58 |

59 |
60 |
61 | 62 | 63 | 64 | 65 | 74 | 75 | 81 | View all posts 82 | 83 | 84 | 85 | 86 | 87 |
88 |

89 | {post.content ?? 'No content available.'} 90 |

91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /apps/web/src/routes/_protected/posts/-components/create-post.tsx: -------------------------------------------------------------------------------- 1 | import { PlusIcon } from '@radix-ui/react-icons'; 2 | import { Button } from '@repo/ui/components/button'; 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogTrigger, 11 | } from '@repo/ui/components/dialog'; 12 | import { Input } from '@repo/ui/components/input'; 13 | import { Label } from '@repo/ui/components/label'; 14 | import { Textarea } from '@repo/ui/components/textarea'; 15 | import { useForm } from '@tanstack/react-form'; 16 | import { useMutation, useQuery } from '@tanstack/react-query'; 17 | import { TRPCClientError } from '@trpc/client'; 18 | import { useState } from 'react'; 19 | import { toast } from 'sonner'; 20 | import * as v from 'valibot'; 21 | import { trpc } from '@/router'; 22 | import FormFieldInfo from '@/routes/-components/common/form-field-info'; 23 | import Spinner from '@/routes/-components/common/spinner'; 24 | 25 | const FormSchema = v.object({ 26 | title: v.pipe( 27 | v.string(), 28 | v.minLength(3, 'Please enter at least 3 characters'), 29 | ), 30 | content: v.pipe( 31 | v.string(), 32 | v.minLength(5, 'Please enter at least 5 characters'), 33 | ), 34 | }); 35 | 36 | const generateTimestamp = () => +new Date(); 37 | 38 | export default function CreatePostButton() { 39 | const getAllPostsQuery = useQuery(trpc.posts.all.queryOptions()); 40 | const createPostMutation = useMutation(trpc.posts.create.mutationOptions()); 41 | const [openDialog, setOpenDialog] = useState(false); 42 | 43 | const form = useForm({ 44 | defaultValues: { 45 | title: `Post ${generateTimestamp()}`, 46 | content: `\ 47 | The year was 2081, and everybody was finally equal. 48 | They weren't only equal before God and the law. 49 | They were equal every which way. 50 | Nobody was smarter than anybody else. 51 | Nobody was better looking than anybody else. 52 | Nobody was stronger or quicker than anybody else. 53 | All this equality was due to the 211th, 212th, and 213th Amendments to the Constitution, 54 | and to the unceasing vigilance of agents of the United States Handicapper General. 55 | 56 | - Harrison Bergeron by Kurt Vonnegut 57 | `, 58 | }, 59 | validators: { 60 | onChange: FormSchema, 61 | }, 62 | onSubmit: async ({ value, formApi }) => { 63 | try { 64 | await createPostMutation.mutateAsync({ 65 | title: value.title, 66 | content: value.content, 67 | }); 68 | setOpenDialog(false); 69 | await getAllPostsQuery.refetch(); 70 | formApi.reset(); 71 | toast.success('Your post has been created!'); 72 | } catch (error) { 73 | if (error instanceof TRPCClientError) { 74 | toast.error(error.message); 75 | } else { 76 | toast.error('An unknown error has occurred. Please try again!'); 77 | } 78 | } 79 | }, 80 | }); 81 | 82 | return ( 83 | 84 | 85 | 89 | 90 | 91 | 92 | Create Post 93 | 94 | Write about an interesting topic! 95 | 96 | 97 |
{ 100 | e.preventDefault(); 101 | e.stopPropagation(); 102 | form.handleSubmit(); 103 | }} 104 | > 105 |
106 | { 109 | return ( 110 | <> 111 | 112 | field.handleChange(e.target.value)} 119 | /> 120 | 121 | 122 | ); 123 | }} 124 | /> 125 |
126 |
127 | { 130 | return ( 131 | <> 132 | 133 |