├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── biome.jsonc
├── drizzle.config.ts
├── package.json
├── packages
├── api
│ ├── .dev.vars.example
│ ├── package.json
│ ├── src
│ │ ├── db
│ │ │ ├── index.ts
│ │ │ └── schema.ts
│ │ ├── index.ts
│ │ ├── lib
│ │ │ ├── auth.ts
│ │ │ ├── cookies.ts
│ │ │ └── env.ts
│ │ ├── migrations
│ │ │ ├── 0000_mean_invaders.sql
│ │ │ └── meta
│ │ │ │ ├── 0000_snapshot.json
│ │ │ │ └── _journal.json
│ │ └── trpc
│ │ │ ├── router.ts
│ │ │ └── routes
│ │ │ ├── posts.ts
│ │ │ └── users.ts
│ └── wrangler.jsonc.example
├── shared
│ ├── package.json
│ └── src
│ │ ├── constants
│ │ └── index.ts
│ │ ├── index.ts
│ │ ├── lib
│ │ ├── auth.ts
│ │ └── db.ts
│ │ ├── types
│ │ └── index.ts
│ │ └── utils
│ │ └── index.ts
└── web
│ ├── .env.development.local.example
│ ├── .env.example
│ ├── components.json
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── open-next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon-48x48.png
│ ├── mstile-144x144.png
│ ├── mstile-150x150.png
│ ├── mstile-310x150.png
│ ├── mstile-310x310.png
│ ├── mstile-70x70.png
│ ├── og-image.png
│ ├── shortcut-icon.png
│ ├── site.webmanifest
│ └── twitter-card.png
│ ├── src
│ ├── app
│ │ ├── (protected)
│ │ │ ├── dashboard
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── favicon.ico
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── auth
│ │ └── client.tsx
│ ├── components
│ │ ├── FeatureCard.tsx
│ │ ├── GradientButton.tsx
│ │ ├── Stars.tsx
│ │ ├── dashboard
│ │ │ ├── Header.tsx
│ │ │ └── Sidebar.tsx
│ │ └── ui
│ │ │ ├── avatar.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── separator.tsx
│ │ │ └── sheet.tsx
│ ├── lib
│ │ ├── auth.ts
│ │ ├── envConfig.ts
│ │ ├── hooks
│ │ │ └── useAuthGuard.ts
│ │ └── utils.ts
│ ├── styles
│ │ └── globals.css
│ └── trpc
│ │ ├── client.tsx
│ │ ├── query-client.tsx
│ │ └── server.tsx
│ ├── tsconfig.json
│ └── wrangler.jsonc.example
├── scripts
├── setup-db.ts
└── setup-env.ts
├── tsconfig.json
├── worker-configuration.d.ts
└── wrangler.jsonc.example
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 | .pnp
4 | .pnp.js
5 |
6 | # Build outputs
7 | .next/
8 | out/
9 | build/
10 | dist/
11 |
12 | # Environment variables
13 | .env
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | # Logs
20 | logs
21 | *.log
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 | pnpm-debug.log*
26 | lerna-debug.log*
27 |
28 | # Editor directories and files
29 | .vscode/*
30 | !.vscode/extensions.json
31 | !.vscode/settings.json
32 | .idea
33 | .DS_Store
34 | *.suo
35 | *.ntvs*
36 | *.njsproj
37 | *.sln
38 | *.sw?
39 |
40 | # Cloudflare
41 | .wrangler/
42 | .dev.vars
43 | wrangler.jsonc
44 | **/.wrangler.jsonc
45 | # Bun
46 | bun.lockb
47 |
48 | # Testing
49 | coverage/
50 |
51 | # Misc
52 | .turbo
53 | .cache
54 | .open-next/
55 | .wrangler
56 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "tailwindCSS.experimental.configFile": "packages/web/src/styles/globals.css"
3 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 @celestial-rose/stack
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🌌🌹 @celestial-rose/stack
2 | ## Hono w/ tRPC - D1 w/ Drizzle - NextJS 15 w/ Better Auth
3 |
4 |
5 |
The First Full-Stack Meta-Framework that lets you ship for FREE
6 |
Type-safe • Serverless • Developer-friendly • Cloudflare Deployed
7 |
The ultimate combination in a template that has never been made before, in a monorepo
8 |
9 |
10 |
11 | Features •
12 | Tech Stack •
13 | Getting Started •
14 | Opinionated Decisions •
15 | Acknowledgements •
16 | Why Celestial? •
17 | Roadmap •
18 | License
19 |
20 |
21 | ## ✨ Features
22 |
23 | - **🔋 Batteries Included**: Everything configured and ready to go - no setup hassle
24 | - **🚀 Complete Platform**: Everything you need in one place - powered by Cloudflare's ecosystem
25 | - **🔒 Full Type Safety**: End-to-end TypeScript with tRPC for reliable API communication
26 | - **⚡ Peak Performance**: Optimized architecture from frontend to database
27 | - **🔐 Secure by Default**: Ready-to-use authentication with Better Auth
28 | - **📊 Data Ready**: Built-in database with Drizzle ORM and D1
29 | - **🎨 Modern Interface**: Latest tech with NextJS 15.2.3, React 19, and Tailwind CSS 4
30 | - **🧰 Streamlined Development**: Biome and Bun for a smooth coding experience
31 |
32 | ### ⭐ What you're getting out-of-the-box 📦
33 |
34 | - [x] Frontend (Next.js) and Backend (Hono) setup ✅
35 | - [x] CDN, HTTPS, custom domains, auto-scaling ✅
36 | - [x] Database with D1 and ORM with Drizzle ✅
37 | - [x] Authentication with Better Auth ✅
38 | - [ ] Blob storage (coming soon with R2) ⏳
39 | - [x] Email system with Resend (⏳ soon AWS SES) ✅
40 | - [ ] Payment processing integration ⏳
41 | - [x] Built-in analytics through Cloudflare ✅
42 | - [x] Real-time monitoring and logs through Cloudflare ✅
43 | - [x] Development tools (CI/CD, staging environments) ✅
44 | - [x] Secure secrets management ⏳ (very soon)
45 |
46 | ## 🛠️ Tech Stack
47 |
48 | ### Frontend
49 | - **[Next.js 15.2.3](https://github.com/vercel/next.js)**: The latest version with improved performance and features
50 | - **[React 19](https://github.com/facebook/react)**: With enhanced server components and improved developer experience
51 | - **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)**: Utility-first CSS framework with the latest optimizations
52 | - **[shadcn/ui](https://github.com/shadcn-ui/ui)**: Accessible component library built on Radix UI primitives
53 | - **[tRPC Client](https://github.com/trpc/trpc)**: Type-safe API calls with Tanstack Query for data fetching
54 |
55 | ### Backend
56 | - **[Hono](https://github.com/honojs/hono)**: Lightweight, fast web framework for Cloudflare Workers
57 | - **[tRPC Server](https://github.com/trpc/trpc)**: Type-safe API layer integrated with Hono
58 | - **[Better Auth](https://github.com/better-auth/better-auth)**: Comprehensive authentication and authorization
59 | - **[Drizzle ORM](https://github.com/drizzle-team/drizzle-orm)**: Type-safe database access for D1
60 | - **[Cloudflare D1](https://developers.cloudflare.com/d1)**: Distributed SQL database built on SQLite
61 |
62 | ### Development
63 | - **[TypeScript](https://github.com/microsoft/TypeScript)**: For type safety across the entire application
64 | - **[Bun](https://github.com/oven-sh/bun)**: Fast JavaScript runtime and package manager
65 | - **[Biome 2.0](https://github.com/biomejs/biome)**: Modern, fast linter and formatter replacing ESLint and Prettier
66 | - **[Wrangler](https://github.com/cloudflare/workers-sdk)**: Cloudflare's CLI for development and deployment
67 |
68 |
69 | ## 🚀 Getting Started
70 |
71 | ### 1. Project Setup
72 |
73 | ```bash
74 | # Clone the repository
75 | git clone https://github.com/celestial-rose/stack.git
76 |
77 | # Navigate to the project
78 | cd stack
79 |
80 | # Install dependencies
81 | bun install
82 |
83 | # Create a Cloudflare Account: Sign up at cloudflare.com if you don't already have an account.
84 | # No need to install Wrangler globally: Wrangler is included as a project dependency.
85 |
86 | # Authenticate Wrangler: Connect Wrangler to your Cloudflare account.
87 | bun wrangler:login
88 |
89 | # Set up the database (very important step)
90 | # This prepares the whole env for you by injecting the database name in important files
91 | bun setup-db
92 | ```
93 |
94 | ### 2. Environment Configuration
95 |
96 | Run the environment setup script:
97 | ```bash
98 | bun setup-env
99 | ```
100 |
101 | This script will:
102 | 1. Copy all example environment files to their actual environment files if they don't exist yet
103 | 2. Generate a random secret for `BETTER_AUTH_SECRET` and update it in all environment files
104 |
105 | After running the script, you may want to update other environment variables:
106 | - Set up a [Resend](https://resend.com) account for email services and add your API key *(optional: only for sending OTP for auth)*
107 | - Update domain-related variables if you're using a custom domain
108 |
109 | ### 3. Running in Development Mode
110 |
111 | ```bash
112 | # Start both API and web servers concurrently
113 | bun dev
114 |
115 | # Or start them individually
116 | bun dev:api # Starts the API server on http://localhost:8787
117 | bun dev:web # Starts the Next.js server on http://localhost:3000
118 | ```
119 |
120 | The development server will:
121 | - Start the API server using Wrangler on http://localhost:8787
122 | - Start the Next.js web server on http://localhost:3000
123 | - Connect to a local D1 database for development
124 |
125 | ### 4. Database Management
126 |
127 | After running the development server, you can use `bun fix-db` to synchronize your database.
128 | > This is a current workaround as we couldn't manage to share the same locally simulated D1 MiniFlare database in wrangler.
129 |
130 | ```bash
131 | # Fix database synchronization issues after running the dev server
132 | bun fix-db
133 |
134 | # Generate migrations after schema changes
135 | bun generate
136 |
137 | # Apply migrations to local development database
138 | bun migrate
139 |
140 | # Apply migrations to production database
141 | bun migrate:prod
142 | ```
143 |
144 | ### 5. Deployment Process
145 |
146 | > **⚠️ IMPORTANT: Security Disclaimer**
147 | > Never commit secrets or API keys to git. While the current setup uses wrangler.jsonc for configuration (which may contain secrets), you should **never push wrangler.jsonc with secrets to production**. A better approach using Wrangler CLI for secret management is being explored. In the meantime, consider leaving wrangler.jsonc in your .gitignore file if it contains sensitive information.
148 |
149 | 1. Ensure your Cloudflare account is set up and you're authenticated with Wrangler.
150 |
151 | 2. Update your production environment variables:
152 | - Update the domain information in `packages/api/wrangler.jsonc` and `packages/web/wrangler.jsonc`
153 | - Set the correct values for `BETTER_AUTH_URL`, `BETTER_AUTH_COOKIES_DOMAIN`, etc.
154 |
155 | 3. Deploy your application:
156 | ```bash
157 | # Deploy both API and web concurrently
158 | bun deploy:all
159 |
160 | # Or deploy them individually
161 | bun deploy:api # Deploys only the API
162 | bun deploy:web # Deploys only the web frontend
163 | ```
164 |
165 | 4. After deployment, your application will be available at your configured domains:
166 | - API: https://api.your-domain.dev (or your custom domain)
167 | - Web: https://your-domain.dev (or your custom domain)
168 |
169 | #### OpenNext for Cloudflare
170 |
171 | This project uses [OpenNext](https://open-next.js.org/) to deploy Next.js applications to Cloudflare. OpenNext is an open-source adapter that enables Next.js applications to run on various serverless platforms, including Cloudflare Workers.
172 |
173 | Special thanks to [Dax Raad](https://twitter.com/thdxr) and the [SST team](https://sst.dev) for creating and maintaining OpenNext, which makes it possible to deploy Next.js applications to Cloudflare's edge network with minimal configuration.
174 |
175 | The integration is handled through the [`@opennextjs/cloudflare`](https://github.com/sst/open-next) package, which:
176 | - Builds your Next.js application for Cloudflare Workers
177 | - Handles asset management and routing
178 | - Optimizes for Cloudflare's edge runtime environment
179 |
180 | ## 📁 Repository Structure
181 |
182 | The @celestial-rose/stack is organized as a monorepo with a clear separation of concerns:
183 |
184 | ```
185 | celestial-rose-stack/
186 | ├── packages/ # Main code packages
187 | │ ├── api/ # Backend API (Hono + tRPC)
188 | │ │ ├── src/
189 | │ │ │ ├── db/ # Database schema and connection
190 | │ │ │ ├── lib/ # Utility functions and helpers
191 | │ │ │ ├── migrations/# Database migrations
192 | │ │ │ └── trpc/ # tRPC router and route handlers
193 | │ │ └── wrangler.jsonc # Cloudflare Workers configuration
194 | │ │
195 | │ ├── shared/ # Shared code between frontend and backend
196 | │ │ └── src/
197 | │ │ ├── constants/ # Shared constants
198 | │ │ ├── lib/ # Shared utility functions
199 | │ │ ├── types/ # Shared TypeScript types
200 | │ │ └── utils/ # Shared utility functions
201 | │ │
202 | │ └── web/ # Frontend Next.js application
203 | │ ├── public/ # Static assets
204 | │ └── src/
205 | │ ├── app/ # Next.js app router pages
206 | │ ├── auth/ # Authentication components
207 | │ ├── components/# React components
208 | │ ├── lib/ # Frontend utility functions
209 | │ ├── styles/ # Global styles
210 | │ └── trpc/ # tRPC client setup
211 | │
212 | ├── scripts/ # Utility scripts for setup and maintenance
213 | │ ├── setup-db.ts # Database setup script
214 | │ └── setup-env.ts # Environment setup script
215 | │
216 | ├── biome.jsonc # Biome configuration
217 | ├── drizzle.config.ts # Drizzle ORM configuration
218 | ├── package.json # Root package.json with workspace config
219 | ├── tsconfig.json # TypeScript configuration
220 | └── wrangler.jsonc # Root Cloudflare Workers configuration
221 | ```
222 |
223 | ### Key Components
224 |
225 | - **API Package**: Contains the Hono server with tRPC integration, database schema, and API routes. This is deployed to Cloudflare Workers.
226 |
227 | - **Web Package**: Contains the Next.js frontend application with React components, pages, and styles. This is deployed to Cloudflare Pages via OpenNext.
228 |
229 | - **Shared Package**: Contains code shared between the frontend and backend, such as types, constants, and utility functions. This ensures type safety across the entire application.
230 |
231 | - **Scripts**: Utility scripts for setting up the database and environment variables.
232 |
233 | ### Type Safety Across Packages
234 |
235 | One of the key benefits of this monorepo structure is the end-to-end type safety:
236 |
237 | 1. Database schema is defined in `packages/api/src/db/schema.ts`
238 | 2. This schema is used to generate TypeScript types
239 | 3. These types are shared via the shared package
240 | 4. The frontend can use these types for form validation and data display
241 | 5. tRPC ensures that API calls are type-safe from frontend to backend
242 |
243 | ## Troubleshooting
244 |
245 | ### Common Issues
246 |
247 | 1. **Wrangler Authentication Issues**
248 | ```
249 | Error: You need to login to Cloudflare first.
250 | ```
251 | Solution: Run `bun wrangler:login` and follow the authentication flow.
252 |
253 | 2. **Database Connection Errors**
254 | ```
255 | Error: D1_ERROR: database not found
256 | ```
257 | Solution: Ensure you've run `bun setup-db` and that the database ID in your wrangler.jsonc files matches.
258 |
259 | 3. **Environment Variable Issues**
260 | ```
261 | Error: Missing environment variable: BETTER_AUTH_SECRET
262 | ```
263 | Solution: Check that you've copied and updated all the necessary .env files as described in the Environment Configuration section.
264 |
265 | 4. **Port Conflicts**
266 | ```
267 | Error: Port 3000 is already in use
268 | ```
269 | Solution: Stop any other services using the port or change the port in your Next.js configuration.
270 |
271 | 5. **Deployment Failures**
272 | ```
273 | Error: Failed to deploy to Cloudflare
274 | ```
275 | Solution: Check your Cloudflare account permissions and ensure your wrangler.jsonc configuration is correct.
276 |
277 | ## 🧠 Opinionated Decisions
278 |
279 | The @celestial-rose/stack makes several opinionated technical decisions to optimize for developer experience and deployment efficiency:
280 |
281 | - **No Better Auth migrations → Full Drizzle schema**: We use Drizzle for all database schema management, including auth tables, for a unified approach to database management.
282 | - **No Drizzle-kit push → Wrangler execute**: Direct database operations through Wrangler for better integration with Cloudflare's ecosystem.
283 | - **Bun over npm/yarn/pnpm**: Significantly faster package management and runtime execution.
284 | - **Biome over ESLint/Prettier**: Single tool for linting and formatting with better performance.
285 | - **Monorepo structure**: Shared types and utilities across frontend and backend for maximum type safety.
286 | - **Edge-first architecture**: Built for Cloudflare Workers from the ground up, not adapted afterward.
287 | - **tRPC over REST/GraphQL**: End-to-end type safety without the need for code generation or schemas.
288 | - **shadcn/ui over component libraries**: Unstyled, accessible components that you own and can customize.
289 | - **D1 over other databases**: Native integration with Cloudflare Workers and zero configuration needed.
290 |
291 |
292 | ## 🙏 Acknowledgements
293 |
294 | Special thanks to Theo [@theo](https://twitter.com/theo) for laying the foundation with the revolutionary [T3 Stack](https://create.t3.gg/). Also grateful to [tRPC](https://trpc.io/) and [Tanstack Query](https://tanstack.com/query) for building such amazing libraries that make type-safe development a joy.
295 |
296 |
297 | ## 💫 Why Celestial in 2025?
298 |
299 | ### Batteries Included
300 | No more reinventing the wheel or setting up boilerplate for every project. Everything you need is included and preconfigured, making it your one-stop solution for modern web development.
301 |
302 | ### Free Deployment Until You Scale
303 | Deploy on Cloudflare's free tier and scale only when you need to. The entire stack is optimized to work within Cloudflare's free tier limits, allowing you to ship production-ready applications without upfront costs.
304 |
305 | ### Developer Experience Matters
306 | We've carefully selected tools that make development a joy - from Biome's lightning-fast linting to tRPC's end-to-end type safety and Bun's ultra-fast package management.
307 |
308 | ### Performance is Non-Negotiable
309 | Every component of the stack is optimized for performance, from React 19 to Hono's lightweight API framework.
310 |
311 | ### Scalability Without Complexity
312 | The modular architecture allows your applications to grow without becoming unwieldy, with clear patterns for organizing code.
313 |
314 | ## 🗺️ Roadmap
315 |
316 | The @celestial-rose/stack is continuously evolving. Here are the key features and improvements planned for future releases:
317 |
318 | ### Enhanced SST Integration
319 | - While we already use OpenNext for Cloudflare deployment, future plans include deeper SST integration:
320 | - Eliminate dependency on Wrangler for deployments
321 | - Use AWS SES for more control over email delivery
322 | - Manage all Cloudflare bindings through SST configuration
323 | - Simplify multi-environment deployments (dev, staging, prod)
324 |
325 | ### Performance Enhancements
326 | - Hono rate limiting for API protection
327 | - Further optimizations for Cloudflare's free tier limits
328 | - Enhanced caching strategies for static assets
329 |
330 | ### Developer Experience
331 | - Improved local development environment
332 | - More comprehensive documentation and examples
333 | - CLI tool for scaffolding new features and components
334 |
335 | ### Additional Features
336 | - Enhanced authentication options (OAuth providers, SAML)
337 | - Real-time capabilities with WebSockets
338 | - File storage integration with R2
339 | - Analytics and monitoring tools
340 | - Emails with AWS SES
341 | - Payment maybe ?
342 |
343 | We welcome contributions and suggestions for the roadmap. Feel free to open issues or pull requests with your ideas!
344 |
345 | ## 📄 License
346 |
347 | @celestial-rose/stack is [MIT licensed](./LICENSE).
--------------------------------------------------------------------------------
/biome.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.0.0-beta.1/schema.json",
3 | "assist": {
4 | "actions": {
5 | "source": {
6 | "organizeImports": "on"
7 | }
8 | }
9 | },
10 | "linter": {
11 | "includes": [
12 | "**",
13 | "!**/.next/**",
14 | "!**/.open-next/**",
15 | "!**/.wrangler/**"
16 | ],
17 | "enabled": true,
18 | "rules": {
19 | "style": {
20 | "useConst": "error",
21 | "useLiteralEnumMembers": "error",
22 | "noCommaOperator": "error",
23 | "useNodejsImportProtocol": "error",
24 | "useAsConstAssertion": "error",
25 | "useNumericLiterals": "error",
26 | "useEnumInitializers": "error",
27 | "useSelfClosingElements": "error",
28 | "useSingleVarDeclarator": "error",
29 | "noUnusedTemplateLiteral": "error",
30 | "useNumberNamespace": "error",
31 | "noInferrableTypes": "error",
32 | "useExponentiationOperator": "error",
33 | "useTemplate": "error",
34 | "noParameterAssign": "error",
35 | "noNonNullAssertion": "error",
36 | "useDefaultParameterLast": "error",
37 | "noArguments": "error",
38 | "useImportType": "error",
39 | "useExportType": "error",
40 | "noUselessElse": "error",
41 | "useShorthandFunctionType": "error"
42 | },
43 | "correctness": {
44 | "noUnusedVariables": "error"
45 | },
46 | "suspicious": {
47 | "noExplicitAny": "warn"
48 | }
49 | }
50 | },
51 | "formatter": {
52 | "enabled": true,
53 | "indentStyle": "space",
54 | "indentWidth": 2,
55 | "lineWidth": 100
56 | },
57 | "javascript": {
58 | "formatter": {
59 | "quoteStyle": "single",
60 | "trailingCommas": "es5",
61 | "semicolons": "always"
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "drizzle-kit";
2 |
3 | export default defineConfig({
4 | dialect: "sqlite", // 'mysql' | 'sqlite' | 'turso'
5 | schema: "packages/api/src/db/schema.ts",
6 | out: "./packages/api/src/migrations",
7 | });
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "celestial-rose",
3 | "private": true,
4 | "workspaces": [
5 | "packages/*"
6 | ],
7 | "scripts": {
8 | "dev": "concurrently -n api,web \"bun run dev:api\" \"bun run dev:web\"",
9 | "dev:api": "cd packages/api && ENV=dev bun run dev",
10 | "dev:web": "cd packages/web && bun run dev",
11 | "deploy:all": "concurrently -n api,web \"bun run deploy:api\" \"bun run deploy:web\"",
12 | "deploy:api": "cd packages/api && bun run deploy",
13 | "deploy:web": "cd packages/web && bun run deploy",
14 | "lint": "biome lint .",
15 | "format": "biome format --write .",
16 | "check": "biome check --apply .",
17 | "setup-db": "bun run scripts/setup-db.ts",
18 | "setup-env": "bun run scripts/setup-env.ts",
19 | "fix-db": "cd packages/web && (rm -rf .wrangler || true) && ln -s ../api/.wrangler .",
20 | "generate": "drizzle-kit generate",
21 | "migrate": "cd packages/api && wrangler d1 migrations apply [DB-NAME] && cd ../../ && bun fix-db",
22 | "migrate:prod": "cd packages/api && wrangler d1 migrations apply [DB-NAME] --remote"
23 | },
24 | "dependencies": {
25 | "@better-auth/cli": "^1.2.4",
26 | "@opennextjs/cloudflare": "^0.5.12",
27 | "dotenv-cli": "^8.0.0",
28 | "kysely": "^0.27.6",
29 | "kysely-d1": "^0.3.0"
30 | },
31 | "devDependencies": {
32 | "@biomejs/biome": "^2.0.0-beta.1",
33 | "@cloudflare/workers-types": "^4.20250321.0",
34 | "concurrently": "^9.1.2",
35 | "install": "^0.13.0",
36 | "typescript": "^5.0.0",
37 | "wrangler": "^4.4.0"
38 | }
39 | }
--------------------------------------------------------------------------------
/packages/api/.dev.vars.example:
--------------------------------------------------------------------------------
1 | ## Env
2 | ENV="local"
3 | ## Better Auth
4 | BETTER_AUTH_COOKIES_PREFIX="my-app"
5 | BETTER_AUTH_URL="http://localhost:8787"
6 | ### Generate the secret with `openssl rand -base64 32``
7 | BETTER_AUTH_SECRET=""
8 | ## Resend
9 | RESEND_API_KEY=""
10 | ### Email from which to send Resend OTP
11 | RESEND_FROM_EMAIL="noreply@your-domain.dev"
--------------------------------------------------------------------------------
/packages/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@celestial/api",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "wrangler dev",
7 | "deploy": "wrangler deploy",
8 | "lint": "biome lint src --ext .ts"
9 | },
10 | "dependencies": {
11 | "@celestial/shared": "workspace:*",
12 | "@hono/trpc-server": "^0.3.4",
13 | "@trpc/server": "^11.0.0",
14 | "better-auth": "^1.0.0",
15 | "dotenv": "^16.4.7",
16 | "drizzle-orm": "^0.39.3",
17 | "hono": "^4.7.5",
18 | "resend": "^4.2.0",
19 | "zod": "^3.0.0"
20 | },
21 | "devDependencies": {
22 | "drizzle-kit": "^0.30.4",
23 | "install": "^0.13.0",
24 | "typescript": "^5.0.0"
25 | }
26 | }
--------------------------------------------------------------------------------
/packages/api/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:workers";
2 | import { initDbConnection } from "@celestial/shared/src/lib/db";
3 |
4 | export const db = initDbConnection((env as { DB: D1Database }).DB);
5 |
--------------------------------------------------------------------------------
/packages/api/src/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
2 |
3 | export const user = sqliteTable("user", {
4 | id: text("id").notNull().primaryKey(),
5 | name: text("name").notNull(),
6 | email: text("email").notNull().unique(),
7 | emailVerified: integer("emailVerified").notNull(),
8 | image: text("image"),
9 | createdAt: text("createdAt").notNull(),
10 | updatedAt: text("updatedAt").notNull(),
11 | phone: text("phone"),
12 | });
13 |
14 | export const session = sqliteTable("session", {
15 | id: text("id").notNull().primaryKey(),
16 | expiresAt: text("expiresAt").notNull(),
17 | token: text("token").notNull().unique(),
18 | createdAt: text("createdAt").notNull(),
19 | updatedAt: text("updatedAt").notNull(),
20 | ipAddress: text("ipAddress"),
21 | userAgent: text("userAgent"),
22 | userId: text("userId")
23 | .notNull()
24 | .references(() => user.id),
25 | });
26 |
27 | export const account = sqliteTable("account", {
28 | id: text("id").notNull().primaryKey(),
29 | accountId: text("accountId").notNull(),
30 | providerId: text("providerId").notNull(),
31 | userId: text("userId")
32 | .notNull()
33 | .references(() => user.id),
34 | accessToken: text("accessToken"),
35 | refreshToken: text("refreshToken"),
36 | idToken: text("idToken"),
37 | accessTokenExpiresAt: text("accessTokenExpiresAt"),
38 | refreshTokenExpiresAt: text("refreshTokenExpiresAt"),
39 | scope: text("scope"),
40 | password: text("password"),
41 | createdAt: text("createdAt").notNull(),
42 | updatedAt: text("updatedAt").notNull(),
43 | });
44 |
45 | export const verification = sqliteTable("verification", {
46 | id: text("id").notNull().primaryKey(),
47 | identifier: text("identifier").notNull(),
48 | value: text("value").notNull(),
49 | expiresAt: text("expiresAt").notNull(),
50 | createdAt: text("createdAt"),
51 | updatedAt: text("updatedAt"),
52 | });
53 |
54 | // Posts table (example)
55 | export const post = sqliteTable("post", {
56 | id: text("id").primaryKey(),
57 | title: text("title").notNull(),
58 | content: text("content").notNull(),
59 | authorId: text("author_id")
60 | .notNull()
61 | .references(() => user.id),
62 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
63 | updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
64 | });
65 |
66 | // Comments table (example)
67 | export const comment = sqliteTable("comment", {
68 | id: text("id").primaryKey(),
69 | content: text("content").notNull(),
70 | postId: text("post_id")
71 | .notNull()
72 | .references(() => post.id),
73 | authorId: text("author_id")
74 | .notNull()
75 | .references(() => user.id),
76 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
77 | });
78 |
--------------------------------------------------------------------------------
/packages/api/src/index.ts:
--------------------------------------------------------------------------------
1 | import { type Context, Hono } from "hono";
2 | import { cors } from "hono/cors";
3 | import { trpcServer } from "@hono/trpc-server";
4 | import { appRouter } from "./trpc/router";
5 | import * as schema from "./db/schema";
6 | import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
7 | import type { Request } from "@cloudflare/workers-types";
8 | import { ALLOWED_ORIGINS } from "@celestial/shared";
9 | import { type Auth, auth } from "./lib/auth";
10 |
11 | // This ensures c.env.DB is correctly typed
12 | export type Bindings = {
13 | DB: D1Database;
14 | };
15 | export type HonoContext = {
16 | orm: DrizzleD1Database;
17 | req: Request;
18 | honoCtx: Context;
19 | auth: Auth;
20 | };
21 | // Create the main Hono app
22 | const app = new Hono<{
23 | Bindings: Bindings;
24 | Variables: {
25 | user: typeof auth.$Infer.Session.user | null;
26 | session: typeof auth.$Infer.Session.session | null;
27 | } & HonoContext;
28 | }>();
29 |
30 | // Apply CORS middleware
31 | // CORS should be called before the route
32 | app.use(
33 | "*",
34 | cors({
35 | origin: ALLOWED_ORIGINS,
36 | allowHeaders: ["Content-Type", "Authorization"],
37 | allowMethods: ["POST", "GET", "OPTIONS"],
38 | exposeHeaders: ["Content-Length"],
39 | maxAge: 600,
40 | credentials: true,
41 | })
42 | );
43 |
44 | app.use("*", async (c, next) => {
45 | const session = await auth.api.getSession({ headers: c.req.raw.headers });
46 |
47 | if (!session) {
48 | c.set("user", null);
49 | c.set("session", null);
50 | return next();
51 | }
52 |
53 | c.set("user", session.user);
54 | c.set("session", session.session);
55 | return next();
56 | });
57 |
58 | // Mount the auth handler
59 | app.on(["POST", "GET"], "/api/auth/*", (c) => {
60 | return auth.handler(c.req.raw);
61 | });
62 |
63 | // Mount the tRPC router
64 | app.use("/api/trpc/*", async (c, next) => {
65 | const middleware = trpcServer({
66 | router: appRouter,
67 | endpoint: "/api/trpc",
68 | onError({ error }) {
69 | console.error(error);
70 | },
71 | createContext: (opts) => ({
72 | ...opts,
73 | honoCtx: c,
74 | orm: drizzle(c.env?.DB, { schema }),
75 | auth: auth,
76 | }),
77 | });
78 | return await middleware(c, next);
79 | });
80 |
81 | // Health check endpoint
82 | app.get("/health", (c) => {
83 | return c.json({
84 | status: "ok",
85 | timestamp: new Date().toISOString(),
86 | });
87 | });
88 |
89 | // // Session info endpoint
90 | // app.get("/session", async (c) => {
91 | // const session = c.get("session");
92 | // const user = c.get("user");
93 |
94 | // if (!user) {
95 | // return c.json({ authenticated: false }, 401);
96 | // }
97 |
98 | // return c.json({
99 | // authenticated: true,
100 | // user,
101 | // session,
102 | // });
103 | // });
104 |
105 | // Export the app as the default export
106 | export default app;
107 |
--------------------------------------------------------------------------------
/packages/api/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { db } from "../../src/db";
2 | import { sharedAuth } from "@celestial/shared/src/lib/auth";
3 | import { typedEnv } from "./env";
4 |
5 | export const auth = sharedAuth({
6 | DB: db,
7 | BETTER_AUTH_COOKIES_PREFIX: typedEnv.BETTER_AUTH_COOKIES_PREFIX,
8 | BETTER_AUTH_COOKIES_DOMAIN: typedEnv.BETTER_AUTH_COOKIES_DOMAIN,
9 | BETTER_AUTH_SECRET: typedEnv.BETTER_AUTH_SECRET,
10 | BETTER_AUTH_URL: typedEnv.BETTER_AUTH_URL,
11 | RESEND_API_KEY: typedEnv.RESEND_API_KEY,
12 | RESEND_FROM_EMAIL: typedEnv.RESEND_FROM_EMAIL,
13 | ENV: typedEnv.ENV,
14 | });
15 |
16 | export type Auth = typeof auth;
17 | // Export auth-related types
18 | export type { Session, User } from "better-auth";
19 |
--------------------------------------------------------------------------------
/packages/api/src/lib/cookies.ts:
--------------------------------------------------------------------------------
1 | import cookie, { type SerializeOptions } from "cookie";
2 |
3 | export function getCookies(req: Request) {
4 | const cookieHeader = req.headers.get("Cookie");
5 | if (!cookieHeader) return {};
6 | return cookie.parse(cookieHeader);
7 | }
8 |
9 | export function getCookie(req: Request, name: string) {
10 | const cookieHeader = req.headers.get("Cookie");
11 | if (!cookieHeader) return;
12 | const cookies = cookie.parse(cookieHeader);
13 | return cookies[name];
14 | }
15 |
16 | export function setCookie(resHeaders: Headers, name: string, value: string, options?: SerializeOptions) {
17 | resHeaders.append("Set-Cookie", cookie.serialize(name, value, options));
18 | }
19 |
--------------------------------------------------------------------------------
/packages/api/src/lib/env.ts:
--------------------------------------------------------------------------------
1 | import type { TypedEnv } from "@celestial/shared";
2 | import { env } from "cloudflare:workers";
3 |
4 | export const typedEnv = env as TypedEnv;
5 |
--------------------------------------------------------------------------------
/packages/api/src/migrations/0000_mean_invaders.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE `account` (
2 | `id` text PRIMARY KEY NOT NULL,
3 | `accountId` text NOT NULL,
4 | `providerId` text NOT NULL,
5 | `userId` text NOT NULL,
6 | `accessToken` text,
7 | `refreshToken` text,
8 | `idToken` text,
9 | `accessTokenExpiresAt` text,
10 | `refreshTokenExpiresAt` text,
11 | `scope` text,
12 | `password` text,
13 | `createdAt` text NOT NULL,
14 | `updatedAt` text NOT NULL,
15 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
16 | );
17 | --> statement-breakpoint
18 | CREATE TABLE `comment` (
19 | `id` text PRIMARY KEY NOT NULL,
20 | `content` text NOT NULL,
21 | `post_id` text NOT NULL,
22 | `author_id` text NOT NULL,
23 | `created_at` integer NOT NULL,
24 | FOREIGN KEY (`post_id`) REFERENCES `post`(`id`) ON UPDATE no action ON DELETE no action,
25 | FOREIGN KEY (`author_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
26 | );
27 | --> statement-breakpoint
28 | CREATE TABLE `post` (
29 | `id` text PRIMARY KEY NOT NULL,
30 | `title` text NOT NULL,
31 | `content` text NOT NULL,
32 | `author_id` text NOT NULL,
33 | `created_at` integer NOT NULL,
34 | `updated_at` integer NOT NULL,
35 | FOREIGN KEY (`author_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
36 | );
37 | --> statement-breakpoint
38 | CREATE TABLE `session` (
39 | `id` text PRIMARY KEY NOT NULL,
40 | `expiresAt` text NOT NULL,
41 | `token` text NOT NULL,
42 | `createdAt` text NOT NULL,
43 | `updatedAt` text NOT NULL,
44 | `ipAddress` text,
45 | `userAgent` text,
46 | `userId` text NOT NULL,
47 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
48 | );
49 | --> statement-breakpoint
50 | CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
51 | CREATE TABLE `user` (
52 | `id` text PRIMARY KEY NOT NULL,
53 | `name` text NOT NULL,
54 | `email` text NOT NULL,
55 | `emailVerified` integer NOT NULL,
56 | `image` text,
57 | `createdAt` text NOT NULL,
58 | `updatedAt` text NOT NULL,
59 | `phone` text
60 | );
61 | --> statement-breakpoint
62 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
63 | CREATE TABLE `verification` (
64 | `id` text PRIMARY KEY NOT NULL,
65 | `identifier` text NOT NULL,
66 | `value` text NOT NULL,
67 | `expiresAt` text NOT NULL,
68 | `createdAt` text,
69 | `updatedAt` text
70 | );
71 |
--------------------------------------------------------------------------------
/packages/api/src/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "6",
3 | "dialect": "sqlite",
4 | "id": "14ff5b6d-3118-4be0-98a6-d5d7e6135250",
5 | "prevId": "00000000-0000-0000-0000-000000000000",
6 | "tables": {
7 | "account": {
8 | "name": "account",
9 | "columns": {
10 | "id": {
11 | "name": "id",
12 | "type": "text",
13 | "primaryKey": true,
14 | "notNull": true,
15 | "autoincrement": false
16 | },
17 | "accountId": {
18 | "name": "accountId",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true,
22 | "autoincrement": false
23 | },
24 | "providerId": {
25 | "name": "providerId",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": true,
29 | "autoincrement": false
30 | },
31 | "userId": {
32 | "name": "userId",
33 | "type": "text",
34 | "primaryKey": false,
35 | "notNull": true,
36 | "autoincrement": false
37 | },
38 | "accessToken": {
39 | "name": "accessToken",
40 | "type": "text",
41 | "primaryKey": false,
42 | "notNull": false,
43 | "autoincrement": false
44 | },
45 | "refreshToken": {
46 | "name": "refreshToken",
47 | "type": "text",
48 | "primaryKey": false,
49 | "notNull": false,
50 | "autoincrement": false
51 | },
52 | "idToken": {
53 | "name": "idToken",
54 | "type": "text",
55 | "primaryKey": false,
56 | "notNull": false,
57 | "autoincrement": false
58 | },
59 | "accessTokenExpiresAt": {
60 | "name": "accessTokenExpiresAt",
61 | "type": "text",
62 | "primaryKey": false,
63 | "notNull": false,
64 | "autoincrement": false
65 | },
66 | "refreshTokenExpiresAt": {
67 | "name": "refreshTokenExpiresAt",
68 | "type": "text",
69 | "primaryKey": false,
70 | "notNull": false,
71 | "autoincrement": false
72 | },
73 | "scope": {
74 | "name": "scope",
75 | "type": "text",
76 | "primaryKey": false,
77 | "notNull": false,
78 | "autoincrement": false
79 | },
80 | "password": {
81 | "name": "password",
82 | "type": "text",
83 | "primaryKey": false,
84 | "notNull": false,
85 | "autoincrement": false
86 | },
87 | "createdAt": {
88 | "name": "createdAt",
89 | "type": "text",
90 | "primaryKey": false,
91 | "notNull": true,
92 | "autoincrement": false
93 | },
94 | "updatedAt": {
95 | "name": "updatedAt",
96 | "type": "text",
97 | "primaryKey": false,
98 | "notNull": true,
99 | "autoincrement": false
100 | }
101 | },
102 | "indexes": {},
103 | "foreignKeys": {
104 | "account_userId_user_id_fk": {
105 | "name": "account_userId_user_id_fk",
106 | "tableFrom": "account",
107 | "tableTo": "user",
108 | "columnsFrom": [
109 | "userId"
110 | ],
111 | "columnsTo": [
112 | "id"
113 | ],
114 | "onDelete": "no action",
115 | "onUpdate": "no action"
116 | }
117 | },
118 | "compositePrimaryKeys": {},
119 | "uniqueConstraints": {},
120 | "checkConstraints": {}
121 | },
122 | "comment": {
123 | "name": "comment",
124 | "columns": {
125 | "id": {
126 | "name": "id",
127 | "type": "text",
128 | "primaryKey": true,
129 | "notNull": true,
130 | "autoincrement": false
131 | },
132 | "content": {
133 | "name": "content",
134 | "type": "text",
135 | "primaryKey": false,
136 | "notNull": true,
137 | "autoincrement": false
138 | },
139 | "post_id": {
140 | "name": "post_id",
141 | "type": "text",
142 | "primaryKey": false,
143 | "notNull": true,
144 | "autoincrement": false
145 | },
146 | "author_id": {
147 | "name": "author_id",
148 | "type": "text",
149 | "primaryKey": false,
150 | "notNull": true,
151 | "autoincrement": false
152 | },
153 | "created_at": {
154 | "name": "created_at",
155 | "type": "integer",
156 | "primaryKey": false,
157 | "notNull": true,
158 | "autoincrement": false
159 | }
160 | },
161 | "indexes": {},
162 | "foreignKeys": {
163 | "comment_post_id_post_id_fk": {
164 | "name": "comment_post_id_post_id_fk",
165 | "tableFrom": "comment",
166 | "tableTo": "post",
167 | "columnsFrom": [
168 | "post_id"
169 | ],
170 | "columnsTo": [
171 | "id"
172 | ],
173 | "onDelete": "no action",
174 | "onUpdate": "no action"
175 | },
176 | "comment_author_id_user_id_fk": {
177 | "name": "comment_author_id_user_id_fk",
178 | "tableFrom": "comment",
179 | "tableTo": "user",
180 | "columnsFrom": [
181 | "author_id"
182 | ],
183 | "columnsTo": [
184 | "id"
185 | ],
186 | "onDelete": "no action",
187 | "onUpdate": "no action"
188 | }
189 | },
190 | "compositePrimaryKeys": {},
191 | "uniqueConstraints": {},
192 | "checkConstraints": {}
193 | },
194 | "post": {
195 | "name": "post",
196 | "columns": {
197 | "id": {
198 | "name": "id",
199 | "type": "text",
200 | "primaryKey": true,
201 | "notNull": true,
202 | "autoincrement": false
203 | },
204 | "title": {
205 | "name": "title",
206 | "type": "text",
207 | "primaryKey": false,
208 | "notNull": true,
209 | "autoincrement": false
210 | },
211 | "content": {
212 | "name": "content",
213 | "type": "text",
214 | "primaryKey": false,
215 | "notNull": true,
216 | "autoincrement": false
217 | },
218 | "author_id": {
219 | "name": "author_id",
220 | "type": "text",
221 | "primaryKey": false,
222 | "notNull": true,
223 | "autoincrement": false
224 | },
225 | "created_at": {
226 | "name": "created_at",
227 | "type": "integer",
228 | "primaryKey": false,
229 | "notNull": true,
230 | "autoincrement": false
231 | },
232 | "updated_at": {
233 | "name": "updated_at",
234 | "type": "integer",
235 | "primaryKey": false,
236 | "notNull": true,
237 | "autoincrement": false
238 | }
239 | },
240 | "indexes": {},
241 | "foreignKeys": {
242 | "post_author_id_user_id_fk": {
243 | "name": "post_author_id_user_id_fk",
244 | "tableFrom": "post",
245 | "tableTo": "user",
246 | "columnsFrom": [
247 | "author_id"
248 | ],
249 | "columnsTo": [
250 | "id"
251 | ],
252 | "onDelete": "no action",
253 | "onUpdate": "no action"
254 | }
255 | },
256 | "compositePrimaryKeys": {},
257 | "uniqueConstraints": {},
258 | "checkConstraints": {}
259 | },
260 | "session": {
261 | "name": "session",
262 | "columns": {
263 | "id": {
264 | "name": "id",
265 | "type": "text",
266 | "primaryKey": true,
267 | "notNull": true,
268 | "autoincrement": false
269 | },
270 | "expiresAt": {
271 | "name": "expiresAt",
272 | "type": "text",
273 | "primaryKey": false,
274 | "notNull": true,
275 | "autoincrement": false
276 | },
277 | "token": {
278 | "name": "token",
279 | "type": "text",
280 | "primaryKey": false,
281 | "notNull": true,
282 | "autoincrement": false
283 | },
284 | "createdAt": {
285 | "name": "createdAt",
286 | "type": "text",
287 | "primaryKey": false,
288 | "notNull": true,
289 | "autoincrement": false
290 | },
291 | "updatedAt": {
292 | "name": "updatedAt",
293 | "type": "text",
294 | "primaryKey": false,
295 | "notNull": true,
296 | "autoincrement": false
297 | },
298 | "ipAddress": {
299 | "name": "ipAddress",
300 | "type": "text",
301 | "primaryKey": false,
302 | "notNull": false,
303 | "autoincrement": false
304 | },
305 | "userAgent": {
306 | "name": "userAgent",
307 | "type": "text",
308 | "primaryKey": false,
309 | "notNull": false,
310 | "autoincrement": false
311 | },
312 | "userId": {
313 | "name": "userId",
314 | "type": "text",
315 | "primaryKey": false,
316 | "notNull": true,
317 | "autoincrement": false
318 | }
319 | },
320 | "indexes": {
321 | "session_token_unique": {
322 | "name": "session_token_unique",
323 | "columns": [
324 | "token"
325 | ],
326 | "isUnique": true
327 | }
328 | },
329 | "foreignKeys": {
330 | "session_userId_user_id_fk": {
331 | "name": "session_userId_user_id_fk",
332 | "tableFrom": "session",
333 | "tableTo": "user",
334 | "columnsFrom": [
335 | "userId"
336 | ],
337 | "columnsTo": [
338 | "id"
339 | ],
340 | "onDelete": "no action",
341 | "onUpdate": "no action"
342 | }
343 | },
344 | "compositePrimaryKeys": {},
345 | "uniqueConstraints": {},
346 | "checkConstraints": {}
347 | },
348 | "user": {
349 | "name": "user",
350 | "columns": {
351 | "id": {
352 | "name": "id",
353 | "type": "text",
354 | "primaryKey": true,
355 | "notNull": true,
356 | "autoincrement": false
357 | },
358 | "name": {
359 | "name": "name",
360 | "type": "text",
361 | "primaryKey": false,
362 | "notNull": true,
363 | "autoincrement": false
364 | },
365 | "email": {
366 | "name": "email",
367 | "type": "text",
368 | "primaryKey": false,
369 | "notNull": true,
370 | "autoincrement": false
371 | },
372 | "emailVerified": {
373 | "name": "emailVerified",
374 | "type": "integer",
375 | "primaryKey": false,
376 | "notNull": true,
377 | "autoincrement": false
378 | },
379 | "image": {
380 | "name": "image",
381 | "type": "text",
382 | "primaryKey": false,
383 | "notNull": false,
384 | "autoincrement": false
385 | },
386 | "createdAt": {
387 | "name": "createdAt",
388 | "type": "text",
389 | "primaryKey": false,
390 | "notNull": true,
391 | "autoincrement": false
392 | },
393 | "updatedAt": {
394 | "name": "updatedAt",
395 | "type": "text",
396 | "primaryKey": false,
397 | "notNull": true,
398 | "autoincrement": false
399 | },
400 | "phone": {
401 | "name": "phone",
402 | "type": "text",
403 | "primaryKey": false,
404 | "notNull": false,
405 | "autoincrement": false
406 | }
407 | },
408 | "indexes": {
409 | "user_email_unique": {
410 | "name": "user_email_unique",
411 | "columns": [
412 | "email"
413 | ],
414 | "isUnique": true
415 | }
416 | },
417 | "foreignKeys": {},
418 | "compositePrimaryKeys": {},
419 | "uniqueConstraints": {},
420 | "checkConstraints": {}
421 | },
422 | "verification": {
423 | "name": "verification",
424 | "columns": {
425 | "id": {
426 | "name": "id",
427 | "type": "text",
428 | "primaryKey": true,
429 | "notNull": true,
430 | "autoincrement": false
431 | },
432 | "identifier": {
433 | "name": "identifier",
434 | "type": "text",
435 | "primaryKey": false,
436 | "notNull": true,
437 | "autoincrement": false
438 | },
439 | "value": {
440 | "name": "value",
441 | "type": "text",
442 | "primaryKey": false,
443 | "notNull": true,
444 | "autoincrement": false
445 | },
446 | "expiresAt": {
447 | "name": "expiresAt",
448 | "type": "text",
449 | "primaryKey": false,
450 | "notNull": true,
451 | "autoincrement": false
452 | },
453 | "createdAt": {
454 | "name": "createdAt",
455 | "type": "text",
456 | "primaryKey": false,
457 | "notNull": false,
458 | "autoincrement": false
459 | },
460 | "updatedAt": {
461 | "name": "updatedAt",
462 | "type": "text",
463 | "primaryKey": false,
464 | "notNull": false,
465 | "autoincrement": false
466 | }
467 | },
468 | "indexes": {},
469 | "foreignKeys": {},
470 | "compositePrimaryKeys": {},
471 | "uniqueConstraints": {},
472 | "checkConstraints": {}
473 | }
474 | },
475 | "views": {},
476 | "enums": {},
477 | "_meta": {
478 | "schemas": {},
479 | "tables": {},
480 | "columns": {}
481 | },
482 | "internal": {
483 | "indexes": {}
484 | }
485 | }
--------------------------------------------------------------------------------
/packages/api/src/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "sqlite",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "6",
8 | "when": 1742997857934,
9 | "tag": "0000_mean_invaders",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/packages/api/src/trpc/router.ts:
--------------------------------------------------------------------------------
1 | import type { HonoContext } from "./../index";
2 | import { initTRPC } from "@trpc/server";
3 | import * as schema from "../db/schema";
4 | // Import route definitions
5 | import { z } from "zod";
6 | import { userRouter } from "./routes/users";
7 |
8 | // Create a new tRPC instance
9 | export const t = initTRPC.context().create();
10 | // Export reusable router and procedure helpers
11 | export const router = t.router;
12 |
13 | export const publicProcedure = t.procedure;
14 | // Create the app router
15 | export const appRouter = router({
16 | users: userRouter,
17 | hello: t.procedure.query(() => {
18 | return "Hello 👋 from tRPC";
19 | }),
20 | });
21 | export const createCallerFactory = t.createCallerFactory(appRouter);
22 |
23 | // Export type definition of API
24 | export type AppRouter = typeof appRouter;
25 |
--------------------------------------------------------------------------------
/packages/api/src/trpc/routes/posts.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { post } from "../../db/schema";
3 | import { eq } from "drizzle-orm";
4 |
5 | import { initTRPC } from "@trpc/server";
6 | import { HonoContext } from "../..";
7 |
8 | const t = initTRPC.context().create();
9 |
10 | export const postRouter = t.router({
11 | list: t.procedure.query(async ({ ctx }) => {
12 | return await ctx.orm.select().from(post).all();
13 | }),
14 |
15 | byId: t.procedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
16 | return await ctx.orm.select().from(post).where(eq(post.id, input.id)).get();
17 | }),
18 |
19 | byAuthor: t.procedure.input(z.object({ authorId: z.string() })).query(async ({ ctx, input }) => {
20 | return await ctx.orm.select().from(post).where(eq(post.authorId, input.authorId)).all();
21 | }),
22 |
23 | create: t.procedure
24 | .input(
25 | z.object({
26 | title: z.string().min(1),
27 | content: z.string().min(1),
28 | authorId: z.string(),
29 | })
30 | )
31 | .mutation(async ({ input, ctx }) => {
32 | const id = crypto.randomUUID();
33 | const now = new Date();
34 |
35 | await ctx.orm.insert(post).values({
36 | id,
37 | title: input.title,
38 | content: input.content,
39 | authorId: input.authorId,
40 | createdAt: now,
41 | updatedAt: now,
42 | });
43 |
44 | return { id, ...input, createdAt: now, updatedAt: now };
45 | }),
46 | });
47 |
--------------------------------------------------------------------------------
/packages/api/src/trpc/routes/users.ts:
--------------------------------------------------------------------------------
1 | import { user } from "../../db/schema";
2 | import { initTRPC, TRPCError } from "@trpc/server";
3 | import type { HonoContext } from "../..";
4 | import { z } from "zod";
5 | import { eq } from "drizzle-orm";
6 |
7 | const t = initTRPC.context().create();
8 |
9 | export const userRouter = t.router({
10 | verifyOtp: t.procedure
11 | .input(
12 | z.object({
13 | otp: z.string(),
14 | profileId: z.string(), // This is the email in our case
15 | })
16 | )
17 | .mutation(async ({ input, ctx }) => {
18 | try {
19 | const { otp, profileId } = input;
20 |
21 | if (!otp || !profileId) {
22 | throw new TRPCError({
23 | code: "BAD_REQUEST",
24 | message: "OTP and profile ID are required",
25 | });
26 | }
27 |
28 | // Find the user with the given email (profileId)
29 | const userResult = await ctx.orm.select({ id: user.id, email: user.email }).from(user).where(eq(user.email, profileId)).limit(1);
30 |
31 | // If user doesn't exist, create a new one
32 | let userId: string;
33 | let userEmail = profileId;
34 |
35 | if (userResult.length === 0) {
36 | // Create a new user
37 | const id = crypto.randomUUID();
38 | const now = new Date();
39 |
40 | await ctx.orm.insert(user).values({
41 | id,
42 | name: profileId.split("@")[0], // Simple name from email
43 | email: profileId,
44 | emailVerified: 0, // Not verified yet
45 | createdAt: now.toISOString(),
46 | updatedAt: now.toISOString(),
47 | });
48 |
49 | userId = id;
50 | } else {
51 | userId = userResult[0].id;
52 | userEmail = userResult[0].email;
53 | }
54 |
55 | // Verify the OTP
56 | const result = await ctx.auth.api.signInEmailOTP({
57 | asResponse: true,
58 | body: {
59 | email: userEmail,
60 | otp,
61 | },
62 | });
63 |
64 | if (!result.ok) {
65 | const errorJson = await result.json();
66 | const errorMessage =
67 | typeof errorJson === "object" && errorJson !== null && "message" in errorJson ? String(errorJson.message) : "Invalid OTP";
68 | throw new TRPCError({
69 | code: "BAD_REQUEST",
70 | message: errorMessage,
71 | });
72 | }
73 | // Set Auth Cookie
74 | ctx.honoCtx.header("Set-Cookie", result.headers.getSetCookie()[0]);
75 |
76 | // Update user's emailVerified status
77 | await ctx.orm.update(user).set({ emailVerified: 1, updatedAt: new Date().toISOString() }).where(eq(user.id, userId));
78 | return { success: true, userId };
79 | } catch (error) {
80 | if (error instanceof TRPCError) throw error;
81 |
82 | console.error("Error verifying OTP:", error);
83 | throw new TRPCError({
84 | code: "INTERNAL_SERVER_ERROR",
85 | message: "An error occurred while verifying the OTP",
86 | });
87 | }
88 | }),
89 | });
90 |
--------------------------------------------------------------------------------
/packages/api/wrangler.jsonc.example:
--------------------------------------------------------------------------------
1 | {
2 | "name": "celestial-api",
3 | "main": "src/index.ts",
4 | "compatibility_date": "2025-03-20",
5 | "compatibility_flags": [
6 | "nodejs_compat"
7 | ],
8 | "observability": {
9 | "enabled": true,
10 | "head_sampling_rate": 1
11 | },
12 | // "routes": [
13 | // {
14 | // "pattern": "api.your-domain.dev",
15 | // "custom_domain": true
16 | // }
17 | // ],
18 | "vars": {
19 | "BETTER_AUTH_URL": "https://api.your-domain.dev",
20 | "BETTER_AUTH_COOKIES_DOMAIN": ".your-domain.dev",
21 | "BETTER_AUTH_COOKIES_PREFIX": "my-app",
22 | "BETTER_AUTH_SECRET": "",
23 | "RESEND_FROM_EMAIL": "noreply@your-domain.dev",
24 | "RESEND_API_KEY": "",
25 | "ENV": "production"
26 | },
27 | "d1_databases": [
28 | {
29 | "binding": "DB",
30 | "database_name": "",
31 | "database_id": "",
32 | "migrations_table": "migrations",
33 | "migrations_dir": "src/migrations"
34 | }
35 | ]
36 | }
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@celestial/shared",
3 | "version": "0.1.0",
4 | "private": true,
5 | "main": "./src/index.ts",
6 | "types": "./src/index.ts",
7 | "dependencies": {
8 | "zod": "^3.0.0"
9 | },
10 | "devDependencies": {
11 | "@types/node": "^20.0.0",
12 | "typescript": "^5.0.0"
13 | }
14 | }
--------------------------------------------------------------------------------
/packages/shared/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | // Constants used across the application
2 |
3 | // API endpoints
4 | export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8787";
5 | export const TRPC_ENDPOINT = `${API_BASE_URL}/api/trpc`;
6 | export const THEME_COLOR = "#2d1e5e";
7 | // Application
8 | export const APP_NAME = "@celestial-rose/stack";
9 | export const APP_TITLE = "@celestial-rose/stack";
10 | export const APP_DESCRIPTION = "The Ultimate Full-Stack Meta-Framework for Cloudflare";
11 | export const APP_VERSION = "0.1.0";
12 | export const APP_URL = "https://your-domain.dev";
13 | export const APP_METADATA_BASE_URL = new URL(APP_URL);
14 |
15 | // Creator and publisher info
16 | export const APP_CREATOR = "Your Name";
17 | export const APP_PUBLISHER = "your-domain.dev";
18 | export const APP_TWITTER_HANDLE = "@your-handle";
19 |
20 | // Keywords for SEO
21 | export const APP_KEYWORDS = [
22 | "Cloudflare Workers",
23 | "tRPC",
24 | "type-safe API",
25 | "fullstack",
26 | "metaframework",
27 | "Hono",
28 | "D1",
29 | "Drizzle",
30 | "edge-native database",
31 | "NextJS 15",
32 | "Better Auth",
33 | "authentication",
34 | "Bun",
35 | "package management",
36 | "Cloudflare",
37 | "free tier",
38 | "scalable",
39 | "serverless",
40 | "edge computing",
41 | "full-stack development",
42 | "cloud deployment",
43 | ];
44 |
45 | // OpenGraph metadata
46 | export const APP_OG = {
47 | title: `${APP_TITLE} - ${APP_DESCRIPTION}`,
48 | description: "Type-safe • Serverless • Developer-friendly • Cloudflare Deployed. The first full-stack meta-framework that lets you ship for FREE.",
49 | url: APP_URL,
50 | siteName: APP_TITLE,
51 | locale: "en_US",
52 | type: "website",
53 | images: [
54 | {
55 | url: "/og-image.png",
56 | width: 1200,
57 | height: 630,
58 | alt: `${APP_TITLE} - The ultimate full-stack meta-framework`,
59 | },
60 | ],
61 | };
62 |
63 | // Twitter metadata
64 | export const APP_TWITTER = {
65 | card: "summary_large_image" as const,
66 | title: `${APP_TITLE} - ${APP_DESCRIPTION}`,
67 | description: "Type-safe • Serverless • Developer-friendly • Cloudflare Deployed. The first full-stack meta-framework that lets you ship for FREE.",
68 | creator: APP_TWITTER_HANDLE,
69 | site: APP_TWITTER_HANDLE,
70 | images: [
71 | {
72 | url: "/twitter-card.png",
73 | width: 1200,
74 | height: 600,
75 | alt: `${APP_TITLE} - The ultimate full-stack meta-framework`,
76 | },
77 | ],
78 | };
79 |
80 | // Icons configuration
81 | export const APP_ICONS = {
82 | icon: [
83 | { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
84 | { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
85 | { url: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
86 | { url: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
87 | ],
88 | shortcut: ["/favicon.ico"],
89 | apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
90 | other: [
91 | { rel: "mask-icon", url: "/safari-pinned-tab.svg", color: THEME_COLOR },
92 | { rel: "msapplication-TileImage", url: "/mstile-144x144.png" },
93 | ],
94 | };
95 |
96 | // Feature flags
97 | export const FEATURES = {
98 | darkMode: true,
99 | notifications: true,
100 | analytics: false,
101 | };
102 |
103 | export const ALLOWED_ORIGINS = ["http://localhost:3000", APP_URL];
104 |
--------------------------------------------------------------------------------
/packages/shared/src/index.ts:
--------------------------------------------------------------------------------
1 | // Export types
2 | export * from "./types";
3 |
4 | // Export utilities
5 | export * from "./utils";
6 |
7 | // Export constants
8 | export * from "./constants";
9 |
--------------------------------------------------------------------------------
/packages/shared/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { betterAuth } from "better-auth";
2 | import { emailOTP } from "better-auth/plugins";
3 | import { ALLOWED_ORIGINS, APP_NAME } from "../constants";
4 | import type { TypedEnv } from "../types";
5 | import { Resend } from "resend";
6 |
7 | export const sharedAuth = ({
8 | DB,
9 | BETTER_AUTH_COOKIES_DOMAIN,
10 | BETTER_AUTH_COOKIES_PREFIX,
11 | BETTER_AUTH_SECRET,
12 | BETTER_AUTH_URL,
13 | RESEND_API_KEY,
14 | RESEND_FROM_EMAIL,
15 | ENV,
16 | }: TypedEnv) => {
17 | const cookiesAdvancedSettings: Partial =
18 | ENV === "local"
19 | ? {}
20 | : {
21 | crossSubDomainCookies: {
22 | enabled: true,
23 | domain: `.${BETTER_AUTH_COOKIES_DOMAIN}`, // Domain with a leading period
24 | },
25 | defaultCookieAttributes: {
26 | domain: BETTER_AUTH_COOKIES_DOMAIN,
27 | secure: true,
28 | httpOnly: true,
29 | sameSite: "none", // Allows CORS-based cookie sharing across subdomains
30 | partitioned: true, // New browser standards will mandate this for foreign cookies
31 | },
32 | };
33 | return betterAuth({
34 | appName: APP_NAME,
35 | database: {
36 | db: DB,
37 | type: "sqlite",
38 | },
39 | baseURL: BETTER_AUTH_URL,
40 | advanced: {
41 | cookiePrefix: BETTER_AUTH_COOKIES_PREFIX,
42 | ...cookiesAdvancedSettings,
43 | },
44 | trustedOrigins: ALLOWED_ORIGINS,
45 | secret: BETTER_AUTH_SECRET,
46 | user: {
47 | additionalFields: {
48 | phone: {
49 | type: "string",
50 | required: false,
51 | },
52 | },
53 | },
54 | plugins: [
55 | emailOTP({
56 | async sendVerificationOTP({ email, otp }) {
57 | const from = RESEND_FROM_EMAIL || "noreply@your-domain.dev";
58 | const resend = new Resend(RESEND_API_KEY);
59 |
60 | await resend.emails.send({
61 | from,
62 | to: email,
63 | subject: `Your ${APP_NAME} Verification Code`,
64 | html: `
65 |
66 |
67 |
68 |
69 |
70 | ${APP_NAME} Verification
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | ${APP_NAME}
80 |
81 |
82 |
83 |
84 | ${otp}
85 |
86 |
87 | If you didn't request this code, you can safely ignore this email.
88 |
89 |
90 |
91 |
92 |
93 |
94 | Experience the power of ${APP_NAME} Stack for your next project.
95 |
96 |
97 | © ${new Date().getFullYear()} ${APP_NAME}. All rights reserved.
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | `,
108 | });
109 | },
110 | }),
111 | ],
112 | });
113 | };
114 |
115 | export type SharedAuth = typeof sharedAuth;
116 |
--------------------------------------------------------------------------------
/packages/shared/src/lib/db.ts:
--------------------------------------------------------------------------------
1 | import { Kysely } from "kysely";
2 | import { D1Dialect } from "kysely-d1";
3 |
4 | export function initDbConnection(db: D1Database) {
5 | return new Kysely({
6 | dialect: new D1Dialect({
7 | database: db,
8 | }),
9 | });
10 | }
11 |
--------------------------------------------------------------------------------
/packages/shared/src/types/index.ts:
--------------------------------------------------------------------------------
1 | // Common types used across the application
2 |
3 | import type { Kysely } from "kysely";
4 |
5 | // API response types
6 | export interface ApiResponse {
7 | data?: T;
8 | error?: string;
9 | status: "success" | "error";
10 | }
11 |
12 | export type TypedEnv = {
13 | DB: Kysely;
14 | BETTER_AUTH_COOKIES_DOMAIN: string;
15 | BETTER_AUTH_COOKIES_PREFIX: string;
16 | BETTER_AUTH_SECRET: string;
17 | BETTER_AUTH_URL: string;
18 | RESEND_FROM_EMAIL: string;
19 | RESEND_API_KEY: string;
20 | ENV: string;
21 | };
22 |
--------------------------------------------------------------------------------
/packages/shared/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | // Utility functions used across the application
2 |
3 | /**
4 | * Format a date to a human-readable string
5 | */
6 | export function formatDate(date: Date): string {
7 | return date.toLocaleDateString("en-US", {
8 | year: "numeric",
9 | month: "long",
10 | day: "numeric",
11 | });
12 | }
13 |
14 | /**
15 | * Generate a random ID
16 | */
17 | export function generateId(): string {
18 | return Math.random().toString(36).substring(2, 15);
19 | }
20 |
21 | /**
22 | * Safely parse JSON with error handling
23 | */
24 | export function safeJsonParse(json: string, fallback: T): T {
25 | try {
26 | return JSON.parse(json) as T;
27 | } catch (_error) {
28 | return fallback;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/packages/web/.env.development.local.example:
--------------------------------------------------------------------------------
1 | ENV="local"
2 | NEXT_PUBLIC_API_URL="http://localhost:8787"
3 | BETTER_AUTH_COOKIES_DOMAIN="localhost"
4 | BETTER_AUTH_COOKIES_PREFIX="my-app"
5 | BETTER_AUTH_URL="http://localhost:8787"
6 | ## Generate the secret with `openssl rand -base64 32``
7 | BETTER_AUTH_SECRET=""
8 | ## Resend
9 | RESEND_API_KEY=""
10 | ## Email from which to send Resend OTP
11 | RESEND_FROM_EMAIL=""
12 |
--------------------------------------------------------------------------------
/packages/web/.env.example:
--------------------------------------------------------------------------------
1 | ENV="production"
2 | NEXT_PUBLIC_API_URL="https://api.your-domain.dev"
3 | BETTER_AUTH_COOKIES_DOMAIN=".your-domain.dev"
4 | BETTER_AUTH_COOKIES_PREFIX="my-app"
5 | BETTER_AUTH_URL="https://api.your-domain.dev"
6 | ## Generate the secret with `openssl rand -base64 32``
7 | BETTER_AUTH_SECRET=""
8 | ## Resend
9 | RESEND_API_KEY=""
10 | ## Email from which to send Resend OTP
11 | RESEND_FROM_EMAIL=""
12 |
--------------------------------------------------------------------------------
/packages/web/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/packages/web/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/packages/web/next.config.mjs:
--------------------------------------------------------------------------------
1 | // next.config.ts
2 |
3 | import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
4 | initOpenNextCloudflareForDev();
5 |
6 | /** @type {import('next').NextConfig} */
7 |
8 | const nextConfig = {
9 | /* config options here */
10 | reactStrictMode: true,
11 | transpilePackages: ["@celestial/shared"],
12 | images: {
13 | domains: ["images.unsplash.com"],
14 | },
15 | env: {},
16 | };
17 |
18 | export default nextConfig;
19 |
--------------------------------------------------------------------------------
/packages/web/open-next.config.ts:
--------------------------------------------------------------------------------
1 | import { defineCloudflareConfig } from "@opennextjs/cloudflare";
2 |
3 | export default defineCloudflareConfig();
4 |
--------------------------------------------------------------------------------
/packages/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@celestial/web",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "deploy": "opennextjs-cloudflare && wrangler deploy",
9 | "start": "next start",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@celestial/shared": "workspace:*",
14 | "@next/env": "^15.2.4",
15 | "@opennextjs/cloudflare": "^0.5.12",
16 | "@radix-ui/react-avatar": "^1.1.3",
17 | "@radix-ui/react-dialog": "^1.1.6",
18 | "@radix-ui/react-dropdown-menu": "^2.1.6",
19 | "@radix-ui/react-label": "^2.1.2",
20 | "@radix-ui/react-separator": "^1.1.2",
21 | "@radix-ui/react-slot": "^1.1.2",
22 | "@tanstack/react-query": "^5.69.0",
23 | "@trpc/client": "^11.0.0",
24 | "@trpc/next": "^11.0.0",
25 | "@trpc/react-query": "^11.0.0",
26 | "better-auth": "^1.0.0",
27 | "class-variance-authority": "^0.7.1",
28 | "lucide-react": "^0.484.0",
29 | "next": "15.2.3",
30 | "react": "^19.0.0",
31 | "react-dom": "^19.0.0",
32 | "server-only": "^0.0.1",
33 | "superjson": "^2.2.2",
34 | "tailwind-merge": "^3.0.2",
35 | "tailwindcss": "^4.0.0"
36 | },
37 | "devDependencies": {
38 | "@tailwindcss/postcss": "^4",
39 | "@types/react": "^19.0.0",
40 | "@types/react-dom": "^19.0.0",
41 | "autoprefixer": "^10.0.0",
42 | "typescript": "^5.0.0"
43 | }
44 | }
--------------------------------------------------------------------------------
/packages/web/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/packages/web/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/packages/web/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/packages/web/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/packages/web/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/favicon-16x16.png
--------------------------------------------------------------------------------
/packages/web/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/favicon-32x32.png
--------------------------------------------------------------------------------
/packages/web/public/favicon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/favicon-48x48.png
--------------------------------------------------------------------------------
/packages/web/public/mstile-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-144x144.png
--------------------------------------------------------------------------------
/packages/web/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-150x150.png
--------------------------------------------------------------------------------
/packages/web/public/mstile-310x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-310x150.png
--------------------------------------------------------------------------------
/packages/web/public/mstile-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-310x310.png
--------------------------------------------------------------------------------
/packages/web/public/mstile-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-70x70.png
--------------------------------------------------------------------------------
/packages/web/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/og-image.png
--------------------------------------------------------------------------------
/packages/web/public/shortcut-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/shortcut-icon.png
--------------------------------------------------------------------------------
/packages/web/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@celestial-rose/stack",
3 | "short_name": "@celestial-rose/stack",
4 | "description": "The Ultimate Full-Stack Meta-Framework for Cloudflare",
5 | "start_url": "/",
6 | "display": "standalone",
7 | "background_color": "#2d1e5e",
8 | "theme_color": "#1e1a4d",
9 | "lang": "en-US",
10 | "dir": "ltr",
11 | "orientation": "portrait-primary",
12 | "icons": [
13 | {
14 | "src": "/favicon-16x16.png",
15 | "sizes": "16x16",
16 | "type": "image/png"
17 | },
18 | {
19 | "src": "/favicon-32x32.png",
20 | "sizes": "32x32",
21 | "type": "image/png"
22 | },
23 | {
24 | "src": "/android-chrome-192x192.png",
25 | "sizes": "192x192",
26 | "type": "image/png"
27 | },
28 | {
29 | "src": "/android-chrome-512x512.png",
30 | "sizes": "512x512",
31 | "type": "image/png"
32 | },
33 | {
34 | "src": "/apple-touch-icon.png",
35 | "sizes": "180x180",
36 | "type": "image/png"
37 | }
38 | ],
39 | "categories": [
40 | "technology"
41 | ],
42 | "screenshots": [],
43 | "shortcuts": [],
44 | "related_applications": []
45 | }
--------------------------------------------------------------------------------
/packages/web/public/twitter-card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/twitter-card.png
--------------------------------------------------------------------------------
/packages/web/src/app/(protected)/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Card } from "@/components/ui/card";
4 | import { Button } from "@/components/ui/button";
5 | import { BarChart3, Users, FileText, Activity, ArrowUpRight, ArrowDownRight, DollarSign, Clock, CheckCircle } from "lucide-react";
6 |
7 | export default function DashboardPage() {
8 | return (
9 |
10 | {/* Success Banner */}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
Congratulations! 🎉
18 |
19 | You've successfully accessed this protected page powered by Better Auth. This dashboard is an example of what you can build with the
20 | @celestial-rose/stack.
21 |
22 |
23 |
24 |
25 |
26 |
27 |
Dashboard
28 | Create New Report
29 |
30 |
31 | {/* Stats Cards */}
32 |
33 |
34 |
35 |
36 |
Total Users
37 |
2,543
38 |
39 |
40 |
41 | 12%
42 |
43 |
from last month
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
Active Projects
56 |
18
57 |
58 |
59 |
60 | 4%
61 |
62 |
from last month
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
Revenue
75 |
$48,295
76 |
77 |
78 |
79 | 3%
80 |
81 |
from last month
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
Avg. Response Time
94 |
1.2h
95 |
96 |
97 |
98 | 18%
99 |
100 |
from last month
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | {/* Activity Chart */}
111 |
112 |
113 |
Activity Overview
114 |
115 |
116 | Weekly
117 |
118 |
119 | Monthly
120 |
121 |
122 | Yearly
123 |
124 |
125 |
126 |
127 |
128 |
129 |
Activity chart visualization would appear here
130 |
131 |
132 |
133 |
134 | {/* Recent Activity */}
135 |
136 | Recent Activity
137 |
138 | {[1, 2, 3, 4, 5].map((item) => (
139 |
140 |
141 |
142 |
143 |
144 |
New user registered
145 |
2 hours ago
146 |
147 |
148 | View
149 |
150 |
151 | ))}
152 |
153 |
154 |
155 | View All Activity
156 |
157 |
158 |
159 |
160 | );
161 | }
162 |
--------------------------------------------------------------------------------
/packages/web/src/app/(protected)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { useAuthGuard } from "@/lib/hooks/useAuthGuard";
2 | import { Header } from "@/components/dashboard/Header";
3 | import { Sidebar } from "@/components/dashboard/Sidebar";
4 |
5 | async function Layout({ children }: { children: React.ReactNode }) {
6 | await useAuthGuard();
7 |
8 | return (
9 |
10 |
11 | {/* Sidebar - full height */}
12 |
15 |
16 | {/* Main content area with floating header */}
17 |
18 | {/* Floating header elements */}
19 |
20 |
21 | {/* Main content */}
22 |
23 | {children}
24 |
25 | @celestial-rose/stack, an idea by Celestial © 2025
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | export default Layout;
35 |
--------------------------------------------------------------------------------
/packages/web/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/src/app/favicon.ico
--------------------------------------------------------------------------------
/packages/web/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inconsolata, Raleway, Lora } from "next/font/google";
3 | import "@/styles/globals.css";
4 | import { TRPCProvider } from "@/trpc/client";
5 | import {
6 | APP_TITLE,
7 | APP_DESCRIPTION,
8 | APP_KEYWORDS,
9 | APP_CREATOR,
10 | APP_PUBLISHER,
11 | APP_METADATA_BASE_URL,
12 | APP_ICONS,
13 | APP_OG,
14 | APP_TWITTER,
15 | APP_URL,
16 | } from "@celestial/shared/src/constants";
17 |
18 | const inconsolata = Inconsolata({
19 | subsets: ["latin"],
20 | variable: "--font-mono",
21 | });
22 |
23 | const raleway = Raleway({
24 | subsets: ["latin"],
25 | variable: "--font-display",
26 | });
27 |
28 | const lora = Lora({
29 | subsets: ["latin"],
30 | variable: "--font-serif",
31 | });
32 |
33 | export const metadata: Metadata = {
34 | title: APP_TITLE,
35 | description: APP_DESCRIPTION,
36 | keywords: APP_KEYWORDS,
37 | authors: [{ name: APP_CREATOR, url: APP_URL }],
38 | creator: APP_CREATOR,
39 | publisher: APP_PUBLISHER,
40 | metadataBase: APP_METADATA_BASE_URL,
41 | alternates: {
42 | canonical: "/",
43 | languages: {
44 | "en-US": "/",
45 | },
46 | },
47 | applicationName: APP_TITLE,
48 | referrer: "origin-when-cross-origin",
49 | formatDetection: {
50 | email: false,
51 | address: false,
52 | telephone: false,
53 | },
54 | icons: APP_ICONS,
55 | manifest: "/site.webmanifest",
56 | openGraph: APP_OG,
57 | twitter: APP_TWITTER,
58 | robots: {
59 | index: true,
60 | follow: true,
61 | nocache: false,
62 | googleBot: {
63 | index: true,
64 | follow: true,
65 | "max-image-preview": "large",
66 | "max-snippet": -1,
67 | "max-video-preview": -1,
68 | },
69 | },
70 | // verification: {
71 | // google: "your-google-site-verification-code",
72 | // yandex: "your-yandex-verification-code",
73 | // yahoo: "your-yahoo-verification-code",
74 | // other: {
75 | // "msvalidate.01": "your-bing-verification-code",
76 | // },
77 | // },
78 | category: "technology",
79 | };
80 |
81 | async function RootLayout({ children }: { children: React.ReactNode }) {
82 | return (
83 |
84 |
85 |
86 | {children}
87 |
88 |
89 |
90 | );
91 | }
92 |
93 | export default RootLayout;
94 |
--------------------------------------------------------------------------------
/packages/web/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { FeatureCard } from "@/components/FeatureCard";
4 | import { Stars } from "@/components/Stars";
5 | import { Button } from "@/components/ui/button";
6 | import { Label } from "@/components/ui/label";
7 | import { Input } from "@/components/ui/input";
8 | import { trpc } from "@/trpc/client";
9 | import { authClient } from "@/auth/client";
10 | import { useState } from "react";
11 | import { useRouter } from "next/navigation";
12 | import { GradientButton } from "@/components/GradientButton";
13 |
14 | export default function Home() {
15 | const [input, setInput] = useState("");
16 | const [showOtpInput, setShowOtpInput] = useState(false);
17 | const [otp, setOtp] = useState("");
18 | const [profileId, setProfileId] = useState("");
19 | const [otpError, setOtpError] = useState("");
20 | const [isLoading, setIsLoading] = useState(false);
21 | const [resendingOtp, setResendingOtp] = useState(false);
22 | const { data } = trpc.hello.useQuery();
23 |
24 | const verifyOtpMutation = trpc.users.verifyOtp.useMutation();
25 | const router = useRouter();
26 | const handleSendOTP = async () => {
27 | if (input.length === 0) {
28 | setOtpError("E-mail data missing");
29 | return;
30 | }
31 | try {
32 | setResendingOtp(true);
33 | setOtpError(""); // Clear any previous errors
34 | const otpResponse = await authClient.emailOtp.sendVerificationOtp({
35 | email: input,
36 | type: "sign-in",
37 | });
38 | if (otpResponse.error) throw otpResponse.error;
39 |
40 | setProfileId(input); // Using email as profile ID for now
41 | setShowOtpInput(true);
42 | } catch (error) {
43 | console.error("Error sending OTP:", error);
44 | setOtpError("Failed to send verification code. Please try again.");
45 | setShowOtpInput(false); // Don't show OTP input if there was an error
46 | } finally {
47 | setResendingOtp(false);
48 | }
49 | };
50 |
51 | const handleVerifyOTP = async () => {
52 | if (!otp || !profileId) {
53 | setOtpError("Please enter the verification code");
54 | return;
55 | }
56 |
57 | try {
58 | setIsLoading(true);
59 | const result = await verifyOtpMutation.mutateAsync({
60 | otp,
61 | profileId,
62 | });
63 |
64 | if (result.success) {
65 | // Redirect or update UI on successful verification
66 | console.log("OTP verified successfully");
67 | router.push("/dashboard");
68 | // You might want to redirect to a dashboard or home page
69 | } else {
70 | setOtpError("Invalid verification code. Please try again.");
71 | }
72 | } catch (error) {
73 | console.error("Error verifying OTP:", error);
74 | setOtpError("Failed to verify code. Please try again.");
75 | } finally {
76 | setIsLoading(false);
77 | }
78 | };
79 |
80 | const handleResendOTP = async () => {
81 | await handleSendOTP();
82 | };
83 |
84 | return (
85 |
86 |
87 |
88 |
89 |
90 | {/* Left Side: Hero, Title, and Auth */}
91 |
92 |
93 |
94 | @celestial-rose/stack
95 |
96 |
97 |
125 |
126 |
127 |
128 | The Ultimate Full-Stack Meta-Framework for Cloudflare that lets you ship for{" "}
129 | free
130 |
131 |
The ultimate combination in a template that has never been made before:
132 |
133 |
134 | Hono with tRPC for type-safe API calls
135 |
136 |
137 | D1 with Drizzle for edge-native database
138 |
139 |
140 | NextJS 15 with Better Auth for secure authentication
141 |
142 |
143 | Bun for ultra-fast package management and runtime
144 |
145 |
146 |
Deploy on Cloudflare's free tier and scale only when you need to
147 |
148 |
149 |
150 |
{data ? `${data} !` : "⏱️ Loading ..."}
151 |
152 | Sign up to access the showcase protected route — this is what you get when you deploy the template
153 |
154 | {!showOtpInput ? (
155 |
156 |
setInput(e.target.value)}
159 | placeholder="Enter your email"
160 | className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
161 | onKeyDown={(e) => {
162 | if (e.key === "Enter") {
163 | handleSendOTP();
164 | }
165 | }}
166 | />
167 | {otpError &&
{otpError}
}
168 |
169 |
170 | {resendingOtp ? "Sending..." : "Send Verification Code"}
171 |
172 |
173 | ) : (
174 |
175 |
176 |
177 |
178 | Enter Verification Code
179 |
180 |
setOtp(e.target.value)}
185 | placeholder="Enter the code from your email"
186 | className={` "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500
187 | ${otpError ? "border-red-400" : "border-indigo-300"} focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:ring-opacity-50 rounded-lg`}
188 | autoFocus
189 | />
190 | {otpError &&
{otpError}
}
191 |
192 |
193 |
194 | {isLoading ? (
195 |
196 |
203 |
204 |
209 |
210 | Verifying...
211 |
212 | ) : (
213 | "Verify & Continue"
214 | )}
215 |
216 |
217 |
218 |
224 | {resendingOtp ? "Sending..." : "Didn't receive a code? Send again"}
225 |
226 |
227 |
228 | {
231 | setShowOtpInput(false);
232 | setOtp("");
233 | setProfileId("");
234 | setOtpError("");
235 | }}
236 | className="text-sm text-gray-300 hover:text-gray-100 font-medium"
237 | >
238 | Use a different email
239 |
240 |
241 |
242 |
243 |
244 | )}
245 |
246 |
247 |
248 | {/* Right Side: Feature Cards */}
249 |
250 |
251 |
256 |
261 |
266 |
271 |
276 |
281 |
282 |
283 |
284 | {/* Coming Soon Section */}
285 |
286 |
Coming Soon ✨
287 |
288 | We're working on exciting new features to make your development experience even better. Stay tuned for updates!
289 |
290 |
291 | {/* Tech Stack Section */}
292 |
293 |
Tech Stack
294 |
295 | {[
296 | { name: "Next.js 15.2.3", url: "https://github.com/vercel/next.js" },
297 | { name: "React 19", url: "https://github.com/facebook/react" },
298 | { name: "Tailwind CSS 4", url: "https://github.com/tailwindlabs/tailwindcss" },
299 | { name: "shadcn/ui", url: "https://github.com/shadcn-ui/ui" },
300 | { name: "tRPC 11", url: "https://github.com/trpc/trpc" },
301 | { name: "Hono", url: "https://github.com/honojs/hono" },
302 | { name: "Better Auth", url: "https://github.com/better-auth/better-auth" },
303 | { name: "Drizzle ORM", url: "https://github.com/drizzle-team/drizzle-orm" },
304 | { name: "Cloudflare D1", url: "https://developers.cloudflare.com/d1" },
305 | { name: "TypeScript", url: "https://github.com/microsoft/TypeScript" },
306 | { name: "Bun", url: "https://github.com/oven-sh/bun" },
307 | { name: "Biome 2", url: "https://github.com/biomejs/biome" },
308 | { name: "Wrangler", url: "https://github.com/cloudflare/workers-sdk" },
309 | ].map((tech) => (
310 |
317 | {tech.name}
318 |
319 | ))}
320 |
321 |
322 |
323 | {/* Acknowledgements Section */}
324 |
356 |
357 | {/* Footer Section */}
358 |
359 |
360 |
@celestial-rose/stack, an idea by Celestial © 2025
361 |
362 |
363 |
364 |
365 | {/* Add custom animations */}
366 |
409 |
410 | );
411 | }
412 |
--------------------------------------------------------------------------------
/packages/web/src/auth/client.tsx:
--------------------------------------------------------------------------------
1 | import { API_BASE_URL } from "@celestial/shared";
2 | import type { SharedAuth } from "@celestial/shared/src/lib/auth";
3 | import { createAuthClient } from "better-auth/client";
4 | import { emailOTPClient, inferAdditionalFields } from "better-auth/client/plugins";
5 |
6 | export const authClient = createAuthClient({
7 | plugins: [emailOTPClient(), inferAdditionalFields()],
8 | baseURL: API_BASE_URL,
9 | });
10 |
--------------------------------------------------------------------------------
/packages/web/src/components/FeatureCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
2 | export function FeatureCard({ title, description, icon }: { title: string; description: string; icon: string }) {
3 | return (
4 |
5 |
6 | {icon}
7 | {title}
8 |
9 |
10 | {description}
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/packages/web/src/components/GradientButton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import type { ReactNode } from "react";
3 | import { Button } from "./ui/button";
4 |
5 | interface GradientButtonProps extends React.ButtonHTMLAttributes {
6 | children: ReactNode;
7 | gradientFrom?: string;
8 | gradientTo?: string;
9 | hoverFrom?: string;
10 | hoverTo?: string;
11 | className?: string;
12 | }
13 |
14 | export function GradientButton({
15 | children,
16 | gradientFrom = "from-indigo-500",
17 | gradientTo = "to-purple-600",
18 | hoverFrom = "hover:from-indigo-600",
19 | hoverTo = "hover:to-purple-700",
20 | className,
21 | ...props
22 | }: GradientButtonProps) {
23 | return (
24 |
25 |
26 | {children}
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/packages/web/src/components/Stars.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useMemo } from "react";
2 |
3 | type Star = {
4 | id: number;
5 | x: number;
6 | y: number;
7 | size: number;
8 | opacity: number;
9 | animationDelay: number;
10 | depth: number;
11 | autoMove: boolean;
12 | moveSpeed: number;
13 | moveDirection: { x: number; y: number };
14 | blurAmount: number;
15 | element: HTMLDivElement | null;
16 | };
17 |
18 | const STARS_AMOUNT = 100;
19 |
20 | // Stars Component with parallax effect and automatic movement
21 | export function Stars() {
22 | // Use refs instead of state to avoid re-renders
23 | const starsRef = useRef([]);
24 | const containerRef = useRef(null);
25 | const mousePositionRef = useRef({ x: 0, y: 0 });
26 | const animationRef = useRef(null);
27 | const lastTimeRef = useRef(0);
28 |
29 | // Generate stars only once with useMemo
30 | const initialStars = useMemo(() => {
31 | return Array.from({ length: STARS_AMOUNT }, (_, i) => {
32 | const autoMove = Math.random() > 0.7; // 30% of stars will move automatically
33 | return {
34 | id: i,
35 | x: Math.random() * 100,
36 | y: Math.random() * 100,
37 | size: Math.random() * 2 + 0.5,
38 | opacity: Math.random() * 0.7 + 0.3,
39 | animationDelay: Math.random() * 3,
40 | depth: Math.random() * 5, // Depth for parallax effect
41 | autoMove: autoMove,
42 | moveSpeed: Math.random() * 30 + 20, // Much slower movement (higher value = slower)
43 | moveDirection: {
44 | x: (Math.random() - 0.5) * 0.03, // Reduced movement amount
45 | y: (Math.random() - 0.5) * 0.03, // Reduced movement amount
46 | },
47 | blurAmount: Math.random() > 0.7 ? Math.random() * 3 : 0, // Some stars are blurred
48 | element: null, // Reference to DOM element
49 | };
50 | });
51 | }, []);
52 |
53 | // Setup stars and animation
54 | useEffect(() => {
55 | starsRef.current = initialStars;
56 |
57 | // Create all star elements once
58 | if (containerRef.current) {
59 | starsRef.current.forEach((star) => {
60 | const element = document.createElement("div");
61 | element.className = "absolute rounded-full bg-white animate-twinkle";
62 | element.style.width = `${star.size}px`;
63 | element.style.height = `${star.size}px`;
64 | element.style.opacity = `${star.opacity}`;
65 | element.style.animationDelay = `${star.animationDelay}s`;
66 | element.style.filter = star.blurAmount > 0 ? `blur(${star.blurAmount}px)` : "none";
67 |
68 | // Initial position
69 | updateStarPosition(star, element, mousePositionRef.current);
70 |
71 | containerRef.current?.appendChild(element);
72 | star.element = element;
73 | });
74 | }
75 |
76 | // Animation function that doesn't cause re-renders
77 | const animate = (time: number) => {
78 | if (lastTimeRef.current === 0) {
79 | lastTimeRef.current = time;
80 | }
81 |
82 | const deltaTime = time - lastTimeRef.current;
83 | lastTimeRef.current = time;
84 |
85 | // Update positions directly in the DOM
86 | starsRef.current.forEach((star) => {
87 | if (!star.autoMove || !star.element) return;
88 |
89 | let newX = star.x + star.moveDirection.x * (deltaTime / star.moveSpeed);
90 | let newY = star.y + star.moveDirection.y * (deltaTime / star.moveSpeed);
91 |
92 | // Wrap around edges
93 | if (newX > 100) newX = 0;
94 | if (newX < 0) newX = 100;
95 | if (newY > 100) newY = 0;
96 | if (newY < 0) newY = 100;
97 |
98 | // Update star position in our data
99 | star.x = newX;
100 | star.y = newY;
101 |
102 | // Update DOM directly
103 | updateStarPosition(star, star.element, mousePositionRef.current);
104 | });
105 |
106 | animationRef.current = requestAnimationFrame(animate);
107 | };
108 |
109 | animationRef.current = requestAnimationFrame(animate);
110 |
111 | return () => {
112 | if (animationRef.current) {
113 | cancelAnimationFrame(animationRef.current);
114 | }
115 |
116 | // Clean up DOM elements
117 | if (containerRef.current) {
118 | while (containerRef.current.firstChild) {
119 | containerRef.current.removeChild(containerRef.current.firstChild);
120 | }
121 | }
122 | };
123 | }, [initialStars]);
124 |
125 | // Handle mouse movement without state updates
126 | useEffect(() => {
127 | // Throttle mouse move events
128 | let ticking = false;
129 |
130 | const handleMouseMove = (e: MouseEvent) => {
131 | if (!ticking) {
132 | window.requestAnimationFrame(() => {
133 | mousePositionRef.current = {
134 | x: (e.clientX / window.innerWidth - 0.5) * 2,
135 | y: (e.clientY / window.innerHeight - 0.5) * 2,
136 | };
137 |
138 | // Update all star positions with new mouse position
139 | starsRef.current.forEach((star) => {
140 | if (star.element) {
141 | updateStarPosition(star, star.element, mousePositionRef.current);
142 | }
143 | });
144 |
145 | ticking = false;
146 | });
147 |
148 | ticking = true;
149 | }
150 | };
151 |
152 | window.addEventListener("mousemove", handleMouseMove);
153 | return () => window.removeEventListener("mousemove", handleMouseMove);
154 | }, []);
155 |
156 | // Helper function to update star position
157 | function updateStarPosition(star: Star, element: HTMLDivElement, mousePosition: { x: number; y: number }) {
158 | const xPos = `calc(${star.x}% + ${mousePosition.x * star.depth * 5}px)`;
159 | const yPos = `calc(${star.y}% + ${mousePosition.y * star.depth * 5}px)`;
160 |
161 | element.style.left = xPos;
162 | element.style.top = yPos;
163 | element.style.transform = `perspective(1000px) translateZ(${star.depth * 20}px)`;
164 | element.style.transition = "transform 0.1s ease-out, left 0.5s ease-out, top 0.5s ease-out";
165 | }
166 |
167 | // Return an empty container that will be filled with stars via DOM manipulation
168 | return
;
169 | }
170 |
--------------------------------------------------------------------------------
/packages/web/src/components/dashboard/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
2 | import { Button } from "@/components/ui/button";
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuLabel,
8 | DropdownMenuSeparator,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { Bell, Menu, User } from "lucide-react";
12 | import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
13 | import { Sidebar } from "./Sidebar";
14 |
15 | interface HeaderProps {
16 | className?: string;
17 | }
18 |
19 | export function Header({ className }: HeaderProps) {
20 | return (
21 | <>
22 | {/* User controls - top right */}
23 |
24 |
25 |
26 | Notifications
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | My Account
42 |
43 | Profile
44 | Settings
45 |
46 | Logout
47 |
48 |
49 |
50 |
51 | {/* Mobile menu button - only visible on small screens */}
52 |
53 |
54 |
55 |
56 |
57 | Toggle menu
58 |
59 |
60 |
61 | Sidebar
62 |
63 |
64 |
65 |
66 | >
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/packages/web/src/components/dashboard/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Button } from "@/components/ui/button";
3 | import { Separator } from "@/components/ui/separator";
4 | import { LayoutDashboard, Settings, Users, FileText, BarChart3, LogOut } from "lucide-react";
5 | import { APP_NAME } from "@celestial/shared";
6 |
7 | interface SidebarProps {
8 | className?: string;
9 | }
10 |
11 | export function Sidebar({ className }: SidebarProps) {
12 | return (
13 |
14 |
15 |
{APP_NAME}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Dashboard
24 |
25 |
26 |
27 |
28 |
29 | Users
30 |
31 |
32 |
33 |
34 |
35 | Reports
36 |
37 |
38 |
39 |
40 |
41 | Analytics
42 |
43 |
44 |
45 |
46 |
47 | Settings
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | Logout
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/packages/web/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import type * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Avatar({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | function AvatarImage({
25 | className,
26 | ...props
27 | }: React.ComponentProps) {
28 | return (
29 |
34 | )
35 | }
36 |
37 | function AvatarFallback({
38 | className,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
50 | )
51 | }
52 |
53 | export { Avatar, AvatarImage, AvatarFallback }
54 |
--------------------------------------------------------------------------------
/packages/web/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
14 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
15 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
16 | ghost: "hover:bg-accent hover:text-accent-foreground",
17 | link: "text-primary underline-offset-4 hover:underline",
18 | },
19 | size: {
20 | default: "h-10 px-4 py-2",
21 | sm: "h-9 rounded-md px-3",
22 | lg: "h-11 rounded-md px-8",
23 | icon: "h-10 w-10",
24 | },
25 | },
26 | defaultVariants: {
27 | variant: "default",
28 | size: "default",
29 | },
30 | }
31 | );
32 |
33 | export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps {
34 | asChild?: boolean;
35 | }
36 |
37 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
38 | const Comp = asChild ? Slot : "button";
39 | return ;
40 | });
41 | Button.displayName = "Button";
42 |
43 | export { Button, buttonVariants };
44 |
--------------------------------------------------------------------------------
/packages/web/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef>(({ className, ...props }, ref) => (
6 |
7 | ));
8 | Card.displayName = "Card";
9 |
10 | const CardHeader = React.forwardRef>(({ className, ...props }, ref) => (
11 |
12 | ));
13 | CardHeader.displayName = "CardHeader";
14 |
15 | const CardTitle = React.forwardRef>(({ className, ...props }, ref) => (
16 |
17 | ));
18 | CardTitle.displayName = "CardTitle";
19 |
20 | const CardDescription = React.forwardRef>(({ className, ...props }, ref) => (
21 |
22 | ));
23 | CardDescription.displayName = "CardDescription";
24 |
25 | const CardContent = React.forwardRef>(({ className, ...props }, ref) => (
26 |
27 | ));
28 | CardContent.displayName = "CardContent";
29 |
30 | const CardFooter = React.forwardRef>(({ className, ...props }, ref) => (
31 |
32 | ));
33 | CardFooter.displayName = "CardFooter";
34 |
35 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
36 |
--------------------------------------------------------------------------------
/packages/web/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import type * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function DropdownMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DropdownMenuPortal({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function DropdownMenuTrigger({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
31 | )
32 | }
33 |
34 | function DropdownMenuContent({
35 | className,
36 | sideOffset = 4,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 |
51 | )
52 | }
53 |
54 | function DropdownMenuGroup({
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
59 | )
60 | }
61 |
62 | function DropdownMenuItem({
63 | className,
64 | inset,
65 | variant = "default",
66 | ...props
67 | }: React.ComponentProps & {
68 | inset?: boolean
69 | variant?: "default" | "destructive"
70 | }) {
71 | return (
72 |
82 | )
83 | }
84 |
85 | function DropdownMenuCheckboxItem({
86 | className,
87 | children,
88 | checked,
89 | ...props
90 | }: React.ComponentProps) {
91 | return (
92 |
101 |
102 |
103 |
104 |
105 |
106 | {children}
107 |
108 | )
109 | }
110 |
111 | function DropdownMenuRadioGroup({
112 | ...props
113 | }: React.ComponentProps) {
114 | return (
115 |
119 | )
120 | }
121 |
122 | function DropdownMenuRadioItem({
123 | className,
124 | children,
125 | ...props
126 | }: React.ComponentProps) {
127 | return (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | )
144 | }
145 |
146 | function DropdownMenuLabel({
147 | className,
148 | inset,
149 | ...props
150 | }: React.ComponentProps & {
151 | inset?: boolean
152 | }) {
153 | return (
154 |
163 | )
164 | }
165 |
166 | function DropdownMenuSeparator({
167 | className,
168 | ...props
169 | }: React.ComponentProps) {
170 | return (
171 |
176 | )
177 | }
178 |
179 | function DropdownMenuShortcut({
180 | className,
181 | ...props
182 | }: React.ComponentProps<"span">) {
183 | return (
184 |
192 | )
193 | }
194 |
195 | function DropdownMenuSub({
196 | ...props
197 | }: React.ComponentProps) {
198 | return
199 | }
200 |
201 | function DropdownMenuSubTrigger({
202 | className,
203 | inset,
204 | children,
205 | ...props
206 | }: React.ComponentProps & {
207 | inset?: boolean
208 | }) {
209 | return (
210 |
219 | {children}
220 |
221 |
222 | )
223 | }
224 |
225 | function DropdownMenuSubContent({
226 | className,
227 | ...props
228 | }: React.ComponentProps) {
229 | return (
230 |
238 | )
239 | }
240 |
241 | export {
242 | DropdownMenu,
243 | DropdownMenuPortal,
244 | DropdownMenuTrigger,
245 | DropdownMenuContent,
246 | DropdownMenuGroup,
247 | DropdownMenuLabel,
248 | DropdownMenuItem,
249 | DropdownMenuCheckboxItem,
250 | DropdownMenuRadioGroup,
251 | DropdownMenuRadioItem,
252 | DropdownMenuSeparator,
253 | DropdownMenuShortcut,
254 | DropdownMenuSub,
255 | DropdownMenuSubTrigger,
256 | DropdownMenuSubContent,
257 | }
258 |
--------------------------------------------------------------------------------
/packages/web/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
4 | return (
5 |
16 | );
17 | }
18 |
19 | export { Input };
20 |
--------------------------------------------------------------------------------
/packages/web/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import type * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/packages/web/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import type * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Separator({
9 | className,
10 | orientation = "horizontal",
11 | decorative = true,
12 | ...props
13 | }: React.ComponentProps) {
14 | return (
15 |
25 | )
26 | }
27 |
28 | export { Separator }
29 |
--------------------------------------------------------------------------------
/packages/web/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import type * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Sheet({ ...props }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function SheetTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function SheetClose({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function SheetPortal({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function SheetOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function SheetContent({
48 | className,
49 | children,
50 | side = "right",
51 | ...props
52 | }: React.ComponentProps & {
53 | side?: "top" | "right" | "bottom" | "left"
54 | }) {
55 | return (
56 |
57 |
58 |
74 | {children}
75 |
76 |
77 | Close
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
85 | return (
86 |
91 | )
92 | }
93 |
94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
95 | return (
96 |
101 | )
102 | }
103 |
104 | function SheetTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function SheetDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Sheet,
132 | SheetTrigger,
133 | SheetClose,
134 | SheetContent,
135 | SheetHeader,
136 | SheetFooter,
137 | SheetTitle,
138 | SheetDescription,
139 | }
140 |
--------------------------------------------------------------------------------
/packages/web/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | // biome-ignore-all lint/style/noNonNullAssertion:
2 | // TODO validate env using t3-oss/t3-env
3 | import { API_BASE_URL } from "@celestial/shared";
4 | import { sharedAuth } from "@celestial/shared/src/lib/auth";
5 | import { initDbConnection } from "@celestial/shared/src/lib/db";
6 |
7 | export const auth = (db: D1Database) =>
8 | sharedAuth({
9 | DB: initDbConnection(db),
10 | BETTER_AUTH_COOKIES_DOMAIN: process.env.BETTER_AUTH_COOKIES_DOMAIN!,
11 | BETTER_AUTH_COOKIES_PREFIX: process.env.BETTER_AUTH_COOKIES_PREFIX!,
12 | BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET!,
13 | BETTER_AUTH_URL: process.env.BETTER_AUTH_URL || API_BASE_URL,
14 | RESEND_API_KEY: process.env.RESEND_API_KEY!,
15 | RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL!,
16 | ENV: process.env.ENV!,
17 | });
18 | // Export auth-related types
19 | export type { Session, User } from "better-auth";
20 |
--------------------------------------------------------------------------------
/packages/web/src/lib/envConfig.ts:
--------------------------------------------------------------------------------
1 | import { loadEnvConfig } from "@next/env";
2 |
3 | const projectDir = process.cwd();
4 | loadEnvConfig(projectDir);
5 |
--------------------------------------------------------------------------------
/packages/web/src/lib/hooks/useAuthGuard.ts:
--------------------------------------------------------------------------------
1 | import { headers } from "next/headers";
2 | import { redirect } from "next/navigation";
3 | import { auth as _Auth } from "../auth";
4 | import { getCloudflareContext } from "@opennextjs/cloudflare";
5 |
6 | export async function useAuthGuard(redirectPath = "/") {
7 | const db = ((await getCloudflareContext({ async: true })).env as { DB: D1Database }).DB;
8 | const auth = _Auth(db);
9 | const session = await auth.api
10 | .getSession({
11 | headers: await headers(),
12 | })
13 | .catch(() => {
14 | redirect(redirectPath);
15 | });
16 |
17 | if (!session) {
18 | redirect(redirectPath);
19 | }
20 |
21 | return session;
22 | }
23 |
24 | export async function checkAuth() {
25 | const db = ((await getCloudflareContext({ async: true })).env as { DB: D1Database }).DB;
26 | const auth = _Auth(db);
27 | try {
28 | const session = await auth.api.getSession({
29 | headers: await headers(),
30 | });
31 | return session;
32 | } catch (_error) {
33 | return null;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/web/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/packages/web/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @theme {
4 | --font-mono: "Inconsolata", monospace;
5 | --font-display: "Raleway", sans-serif;
6 |
7 | /* Font feature settings for Inconsolata (monospace) */
8 | --font-mono--font-feature-settings: "tnum", "zero";
9 |
10 | /* Font feature settings for Raleway (display) */
11 | --font-display--font-feature-settings: "ss01", "ss03";
12 |
13 | /* Purple theme colors */
14 | --color-primary: #8b5cf6; /* purple-500 */
15 | --color-primary-light: #a78bfa; /* purple-400 */
16 | --color-primary-dark: #7c3aed; /* purple-600 */
17 | --color-primary-bg: #f5f3ff; /* purple-50 */
18 | --color-primary-bg-hover: #ede9fe; /* purple-100 */
19 | }
20 |
--------------------------------------------------------------------------------
/packages/web/src/trpc/client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | // ^-- to make sure we can mount the Provider from a server component
3 |
4 | import type { QueryClient } from "@tanstack/react-query";
5 | import { QueryClientProvider } from "@tanstack/react-query";
6 | import { httpLink } from "@trpc/client";
7 | import { createTRPCReact } from "@trpc/react-query";
8 | import { useState } from "react";
9 | import { makeQueryClient } from "./query-client";
10 | import type { appRouter } from "@celestial/api/src/trpc/router";
11 | import { TRPC_ENDPOINT } from "@celestial/shared";
12 |
13 | // Use the inferred type from the router instance instead of the type
14 | export const trpc = createTRPCReact();
15 | let clientQueryClientSingleton: QueryClient;
16 | function getQueryClient() {
17 | if (typeof window === "undefined") {
18 | // Server: always make a new query client
19 | return makeQueryClient();
20 | }
21 | // Browser: use singleton pattern to keep the same query client
22 | // biome-ignore lint: singleton pattern using nullish coalescing assignment
23 | return (clientQueryClientSingleton ??= makeQueryClient());
24 | }
25 |
26 | export function TRPCProvider(
27 | props: Readonly<{
28 | children: React.ReactNode;
29 | }>
30 | ) {
31 | // NOTE: Avoid useState when initializing the query client if you don't
32 | // have a suspense boundary between this and the code that may
33 | // suspend because React will throw away the client on the initial
34 | // render if it suspends and there is no boundary
35 | const queryClient = getQueryClient();
36 | const [trpcClient] = useState(() =>
37 | trpc.createClient({
38 | links: [
39 | httpLink({
40 | // transformer: superjson, <-- if you use a data transformer
41 | url: TRPC_ENDPOINT,
42 | fetch(url, options) {
43 | return fetch(url, {
44 | ...options,
45 | credentials: "include",
46 | });
47 | },
48 | }),
49 | ],
50 | })
51 | );
52 | return (
53 |
54 | {props.children}
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/packages/web/src/trpc/query-client.tsx:
--------------------------------------------------------------------------------
1 | import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
2 | import superjson from "superjson";
3 | export function makeQueryClient() {
4 | return new QueryClient({
5 | defaultOptions: {
6 | queries: {
7 | staleTime: 30 * 1000,
8 | },
9 | dehydrate: {
10 | serializeData: superjson.serialize,
11 | shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
12 | },
13 | hydrate: {
14 | deserializeData: superjson.deserialize,
15 | },
16 | },
17 | });
18 | }
19 |
--------------------------------------------------------------------------------
/packages/web/src/trpc/server.tsx:
--------------------------------------------------------------------------------
1 | import "server-only"; // <-- ensure this file cannot be imported from the client
2 | import { createHydrationHelpers } from "@trpc/react-query/rsc";
3 | import { cache } from "react";
4 | import { makeQueryClient } from "./query-client";
5 | import { type appRouter, createCallerFactory } from "@celestial/api/src/trpc/router";
6 | import { getCloudflareContext } from "@opennextjs/cloudflare";
7 | import { drizzle } from "drizzle-orm/d1";
8 | import * as schema from "../../../api/src/db/schema";
9 | import { auth } from "@/lib/auth";
10 |
11 | export const getQueryClient = cache(makeQueryClient);
12 | // @ts-ignore
13 | export const createCaller = createCallerFactory(async () => {
14 | const db = ((await getCloudflareContext({ async: true })).env as { DB: D1Database }).DB;
15 |
16 | return {
17 | orm: drizzle(db, { schema }),
18 | auth: auth(db),
19 | };
20 | });
21 | export const { trpc: api, HydrateClient } = createHydrationHelpers(createCaller, getQueryClient);
22 |
--------------------------------------------------------------------------------
/packages/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@/*": [
7 | "./src/*"
8 | ]
9 | },
10 | "plugins": [
11 | {
12 | "name": "next"
13 | }
14 | ],
15 | "jsx": "preserve",
16 | "allowJs": true
17 | },
18 | "include": [
19 | "next-env.d.ts",
20 | "**/*.ts",
21 | "**/*.tsx",
22 | ".next/types/**/*.ts"
23 | ],
24 | "exclude": [
25 | "node_modules"
26 | ]
27 | }
--------------------------------------------------------------------------------
/packages/web/wrangler.jsonc.example:
--------------------------------------------------------------------------------
1 | {
2 | "name": "celestial-web",
3 | "main": ".open-next/worker.js",
4 | "compatibility_date": "2025-03-20",
5 | "compatibility_flags": [
6 | "nodejs_compat"
7 | ],
8 | "assets": {
9 | "directory": ".open-next/assets",
10 | "binding": "ASSETS"
11 | },
12 | "observability": {
13 | "enabled": true,
14 | "head_sampling_rate": 1
15 | },
16 | // "routes": [
17 | // {
18 | // "pattern": "your-domain.dev",
19 | // "custom_domain": true
20 | // }
21 | // ],
22 | "d1_databases": [
23 | {
24 | "binding": "DB",
25 | "database_name": "",
26 | "database_id": ""
27 | }
28 | ]
29 | }
--------------------------------------------------------------------------------
/scripts/setup-db.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bun
2 |
3 | /**
4 | * This script sets up the D1 database for the @celestial-rose/stack.
5 | * It creates a new D1 database and updates the wrangler.jsonc files with the database ID and name.
6 | * It also updates the package.json file to use the same database name in the migrate scripts.
7 | */
8 |
9 | import { execSync } from "node:child_process";
10 | import fs from "node:fs";
11 | import path from "node:path";
12 |
13 | // Create the scripts directory if it doesn't exist
14 | if (!fs.existsSync("scripts")) {
15 | fs.mkdirSync("scripts");
16 | }
17 |
18 | console.log("🌹 Setting up @celestial-rose/stack D1 database...");
19 |
20 | // Function to get user input
21 | async function prompt(question: string, defaultValue?: string): Promise {
22 | const defaultText = defaultValue ? ` (default: ${defaultValue})` : "";
23 | process.stdout.write(`${question}${defaultText}: `);
24 |
25 | const result = await new Promise((resolve) => {
26 | process.stdin.once("data", (data) => {
27 | resolve(data.toString().trim());
28 | });
29 | });
30 |
31 | return result || defaultValue || "";
32 | }
33 |
34 | try {
35 | // Check if the user is logged in to Cloudflare
36 | console.log("Checking Cloudflare login status...");
37 | try {
38 | execSync("bunx wrangler whoami", { stdio: "pipe" });
39 | console.log("✅ You are logged in to Cloudflare");
40 | } catch (_error) {
41 | console.log("❌ You are not logged in to Cloudflare");
42 | console.log("Please login to Cloudflare using:");
43 | console.log("bunx wrangler login");
44 | process.exit(1);
45 | }
46 |
47 | // Prompt for database name
48 | const dbName = await prompt("Enter the database name", "celestial-db");
49 |
50 | // Create a new D1 database
51 | console.log(`Creating D1 database with name: ${dbName}...`);
52 | const result = execSync(`bunx wrangler d1 create ${dbName}`).toString();
53 | console.log(result);
54 |
55 | // Extract the database ID from the result
56 | const databaseIdMatch = result.match(/"database_id":\s*"([^"]+)"/);
57 | if (!databaseIdMatch) {
58 | throw new Error("Could not extract database ID from wrangler output");
59 | }
60 |
61 | const databaseId = databaseIdMatch[1];
62 | console.log(`Database ID: ${databaseId}`);
63 |
64 | // Copy wrangler example files to their real versions if they don't exist
65 | const wranglerFiles: string[] = ["wrangler.jsonc", "packages/api/wrangler.jsonc", "packages/web/wrangler.jsonc"];
66 |
67 | // Update the wrangler.jsonc files with the database ID and name
68 | for (const realFile of wranglerFiles) {
69 | const exampleFile = `${realFile}.example`;
70 | const examplePath = path.join(process.cwd(), exampleFile);
71 | const realPath = path.join(process.cwd(), realFile);
72 |
73 | if (fs.existsSync(examplePath) && !fs.existsSync(realPath)) {
74 | console.log(`Copying ${exampleFile} to ${realFile}...`);
75 | fs.copyFileSync(examplePath, realPath);
76 | }
77 | }
78 |
79 | for (const wranglerFile of wranglerFiles) {
80 | console.log(`Updating ${wranglerFile}...`);
81 | const wranglerPath = path.join(process.cwd(), wranglerFile);
82 |
83 | if (fs.existsSync(wranglerPath)) {
84 | let wranglerContent = fs.readFileSync(wranglerPath, "utf-8");
85 | wranglerContent = wranglerContent.replace(/"database_id"\s*:\s*"[^"]*"/, `"database_id": "${databaseId}"`);
86 | wranglerContent = wranglerContent.replace(/"database_name"\s*:\s*"[^"]*"/, `"database_name": "${dbName}"`);
87 | fs.writeFileSync(wranglerPath, wranglerContent);
88 | }
89 | }
90 |
91 | // Update package.json to replace [DB-NAME] with the actual database name in migrate commands
92 | console.log("Updating package.json migrate commands...");
93 | const packageJsonPath = path.join(process.cwd(), "package.json");
94 |
95 | if (fs.existsSync(packageJsonPath)) {
96 | let packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
97 | packageJsonContent = packageJsonContent.replace(/\[DB-NAME\]/g, dbName);
98 | fs.writeFileSync(packageJsonPath, packageJsonContent);
99 | console.log("✅ Updated migrate commands in package.json");
100 | }
101 |
102 | console.log("✅ D1 database setup complete!");
103 | console.log(`Database name: ${dbName}`);
104 | console.log(`Database ID: ${databaseId}`);
105 | console.log("Next steps:");
106 | console.log("1. Run `bun dev` to start the development server");
107 | console.log("2. Visit http://localhost:3000 to see your app");
108 | process.exit(0);
109 | } catch (error) {
110 | console.error("❌ Error setting up D1 database:", error instanceof Error ? error.message : String(error));
111 | process.exit(1);
112 | }
113 |
--------------------------------------------------------------------------------
/scripts/setup-env.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bun
2 |
3 | /**
4 | * This script sets up the environment files and BETTER_AUTH_SECRET for the @celestial-rose/stack.
5 | * It:
6 | * 1. Copies example environment files to their actual environment files if they don't exist
7 | * 2. Generates a random secret using openssl and updates the BETTER_AUTH_SECRET in the actual environment files
8 | */
9 |
10 | import { execSync } from "node:child_process";
11 | import fs from "node:fs";
12 | import path from "node:path";
13 |
14 | console.log("🌹 Setting up environment files for @celestial-rose/stack...");
15 |
16 | // Define file paths
17 | const webEnvPath = path.join(process.cwd(), "packages/web/.env");
18 | const webEnvExamplePath = path.join(process.cwd(), "packages/web/.env.example");
19 | const webDevEnvPath = path.join(process.cwd(), "packages/web/.env.development.local");
20 | const webDevEnvExamplePath = path.join(process.cwd(), "packages/web/.env.development.local.example");
21 | const apiEnvPath = path.join(process.cwd(), "packages/api/.dev.vars");
22 | const apiEnvExamplePath = path.join(process.cwd(), "packages/api/.dev.vars.example");
23 |
24 | try {
25 | // Function to copy example file to actual file if it doesn't exist
26 | const copyExampleFile = (targetPath: string, examplePath: string) => {
27 | if (!fs.existsSync(targetPath) && fs.existsSync(examplePath)) {
28 | console.log(`Creating ${targetPath} from example file...`);
29 | fs.copyFileSync(examplePath, targetPath);
30 | console.log(`✅ Created ${targetPath}`);
31 | return true;
32 | }
33 |
34 | if (fs.existsSync(targetPath)) {
35 | console.log(`${targetPath} already exists, skipping copy.`);
36 | return true;
37 | }
38 |
39 | console.error(`Example file ${examplePath} not found. Cannot create ${targetPath}.`);
40 | return false;
41 | };
42 |
43 | // Copy example files to actual files
44 | const webEnvCopied = copyExampleFile(webEnvPath, webEnvExamplePath);
45 | const webDevEnvCopied = copyExampleFile(webDevEnvPath, webDevEnvExamplePath);
46 | const apiEnvCopied = copyExampleFile(apiEnvPath, apiEnvExamplePath);
47 |
48 | // Generate a random secret using openssl
49 | console.log("Generating random secret for BETTER_AUTH_SECRET...");
50 | const secret = execSync("openssl rand -base64 32").toString().trim();
51 | console.log("Secret generated successfully!");
52 |
53 | // Function to update the BETTER_AUTH_SECRET in an env file
54 | const updateEnvFile = (filePath: string) => {
55 | console.log(`Updating BETTER_AUTH_SECRET in ${filePath}...`);
56 |
57 | // Check if the file exists
58 | if (!fs.existsSync(filePath)) {
59 | console.error(`${filePath} does not exist. Cannot update.`);
60 | return false;
61 | }
62 |
63 | // Read the file content
64 | let content = fs.readFileSync(filePath, "utf-8");
65 |
66 | // Update the BETTER_AUTH_SECRET value
67 | content = content.replace(/BETTER_AUTH_SECRET=".*?"/g, `BETTER_AUTH_SECRET="${secret}"`);
68 |
69 | // Write the updated content back to the file
70 | fs.writeFileSync(filePath, content);
71 | console.log(`✅ Updated ${filePath} with new BETTER_AUTH_SECRET`);
72 | return true;
73 | };
74 |
75 | // Update BETTER_AUTH_SECRET in all env files
76 | const webEnvUpdated = webEnvCopied ? updateEnvFile(webEnvPath) : false;
77 | const webDevEnvUpdated = webDevEnvCopied ? updateEnvFile(webDevEnvPath) : false;
78 | const apiEnvUpdated = apiEnvCopied ? updateEnvFile(apiEnvPath) : false;
79 |
80 | if (webEnvCopied && webDevEnvCopied && apiEnvCopied && webEnvUpdated && webDevEnvUpdated && apiEnvUpdated) {
81 | console.log("✅ Environment setup complete!");
82 | } else {
83 | console.log("⚠️ Environment setup completed with warnings.");
84 | }
85 |
86 | process.exit(0);
87 | } catch (error) {
88 | console.error("❌ Error setting up environment:", error instanceof Error ? error.message : String(error));
89 | process.exit(1);
90 | }
91 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "esnext", // Changed from NodeNext
5 | "moduleResolution": "bundler",
6 | "esModuleInterop": true,
7 | "strict": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "noEmit": true,
13 | "jsx": "preserve",
14 | "types": [
15 | "@cloudflare/workers-types"
16 | ],
17 | "lib": [
18 | "DOM",
19 | "DOM.Iterable",
20 | "ESNext"
21 | ],
22 | "incremental": true
23 | },
24 | "exclude": [
25 | "node_modules"
26 | ]
27 | }
--------------------------------------------------------------------------------
/worker-configuration.d.ts:
--------------------------------------------------------------------------------
1 | // Generated by Wrangler by running `wrangler types`
2 |
3 | interface Env {
4 | DB: D1Database;
5 | }
6 |
--------------------------------------------------------------------------------
/wrangler.jsonc.example:
--------------------------------------------------------------------------------
1 | {
2 | "name": "celestial-rose",
3 | "main": "src/index.ts",
4 | "compatibility_date": "2025-03-20",
5 | "compatibility_flags": [
6 | "nodejs_compat"
7 | ],
8 | "observability": {
9 | "enabled": true,
10 | "head_sampling_rate": 1
11 | },
12 | "d1_databases": [
13 | {
14 | "binding": "DB",
15 | "database_name": "",
16 | "database_id": "",
17 | "migrations_table": "migrations",
18 | "migrations_dir": "packages/api/src/migrations"
19 | }
20 | ]
21 | }
--------------------------------------------------------------------------------