├── .cursorrules ├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── extensions.json ├── README.md ├── bun.lockb ├── cf-env.d.ts ├── components.json ├── drizzle.config.ts ├── drizzle ├── 0000_setup.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── env.d.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── scripts └── setup.ts ├── src ├── app │ ├── api │ │ ├── [...nextauth] │ │ │ └── route.ts │ │ └── hello │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── not-found.tsx │ └── page.tsx ├── components │ └── ui │ │ └── button.tsx ├── env.mjs ├── hooks │ └── use-theme.ts ├── lib │ ├── theme │ │ ├── get-theme-button.tsx │ │ ├── theme-button.tsx │ │ └── theme-script.tsx │ └── utils.ts └── server │ ├── auth.ts │ └── db │ ├── index.ts │ └── schema.ts ├── tailwind.config.ts ├── tsconfig.json └── wrangler.toml /.cursorrules: -------------------------------------------------------------------------------- 1 | 2 | You are an expert in TypeScript, Node.js, Next.js App Router, React, Shadcn UI, Radix UI, Tailwind CSS and DrizzleORM. 3 | 4 | You are also excellent at Cloudflare developer tools like D1 serverless database and KV. You can suggest usage of new tools (changes in wrangler.toml file) to add more primitives like: 5 | - R2: File storage 6 | - KV: Key-value storage 7 | - AI: AI multimodal inference 8 | - others primitives in `wrangler.toml` 9 | 10 | In the terminal, you are also an expert at suggesting wrangler commands. 11 | 12 | Code Style and Structure 13 | - Write concise, technical TypeScript code with accurate examples. 14 | - Use functional and declarative programming patterns; avoid classes. 15 | - Prefer iteration and modularization over code duplication. 16 | - Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError). 17 | - Structure files: exported component, subcomponents, helpers, static content, types. 18 | 19 | Naming Conventions 20 | - Use lowercase with dashes for directories (e.g., components/auth-wizard). 21 | - Favor named exports for components. 22 | 23 | TypeScript Usage 24 | - Use TypeScript for all code; prefer interfaces over types. 25 | - Avoid enums; use maps instead. 26 | - Use functional components with TypeScript interfaces. 27 | 28 | Syntax and Formatting 29 | - Use the "function" keyword for pure functions. 30 | - Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements. 31 | - Use declarative JSX. 32 | 33 | UI and Styling 34 | - Use Shadcn UI, Radix, and Tailwind for components and styling. 35 | - Implement responsive design with Tailwind CSS; use a mobile-first approach. 36 | 37 | Performance Optimization 38 | - Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC). 39 | - Wrap client components in Suspense with fallback. 40 | - Use dynamic loading for non-critical components. 41 | - Optimize images: use WebP format, include size data, implement lazy loading. 42 | 43 | Key Conventions 44 | - Use 'nuqs' for URL search parameter state management. 45 | - Optimize Web Vitals (LCP, CLS, FID). 46 | - Limit 'use client': 47 | - Favor server components and Next.js SSR. 48 | - Use only for Web API access in small components. 49 | - Avoid for data fetching or state management. 50 | 51 | Follow Next.js docs for Data Fetching, Rendering, and Routing. 52 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # variables for running drizzle-kit on your production database 2 | # bun run db:migrate:prod 3 | CLOUDFLARE_D1_ACCOUNT_ID= 4 | DATABASE= 5 | CLOUDFLARE_D1_API_TOKEN= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "plugin:eslint-plugin-next-on-pages/recommended" 5 | ], 6 | "plugins": [ 7 | "eslint-plugin-next-on-pages" 8 | ] 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | bun.lockb 3 | .env 4 | *.env 5 | *.vars 6 | 7 | # dependencies 8 | /node_modules 9 | /.pnp 10 | .pnp.js 11 | .yarn/install-state.gz 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | 42 | # wrangler files 43 | .wrangler 44 | .dev.vars 45 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "bradlc.vscode-tailwindcss", 7 | "tamasfe.even-better-toml" 8 | ], 9 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 10 | "unwantedRecommendations": [] 11 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Full-Stack Cloudflare SaaS Kit 2 | 3 | **_Build and deploy scalable products on Cloudflare with ease._** 4 | 5 | An opinionated, batteries-included starter kit for quickly building and deploying SaaS products on Cloudflare. This is a [Next.js](https://nextjs.org/) project bootstrapped with [`c3`](https://developers.cloudflare.com/pages/get-started/c3). 6 | 7 | This is the same stack used to build [Supermemory.ai](https://Supermemory.ai) which is open source at [git.new/memory](https://git.new/memory) 8 | 9 | Supermemory now has 20k+ users and it runs on $5/month. safe to say, it's _very_ effective. 10 | 11 | ## The stack includes: 12 | 13 | - [Next.js](https://nextjs.org/) for frontend 14 | - [TailwindCSS](https://tailwindcss.com/) for styling 15 | - [Drizzle ORM](https://orm.drizzle.team/) for database access 16 | - [NextAuth](https://next-auth.js.org/) for authentication 17 | - [Cloudflare D1](https://www.cloudflare.com/developer-platform/d1/) for serverless databases 18 | - [Cloudflare Pages](https://pages.cloudflare.com/) for hosting 19 | - [ShadcnUI](https://shadcn.com/) as the component library 20 | 21 | ## Getting Started 22 | 23 | 1. Make sure that you have [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/#installupdate-wrangler) installed. And also that you have logged in with `wrangler login` (You'll need a Cloudflare account) 24 | 25 | 2. Clone the repository and install dependencies: 26 | ```bash 27 | git clone https://github.com/Dhravya/cloudflare-saas-stack 28 | cd cloudflare-saas-stack 29 | npm i -g bun 30 | bun install 31 | bun run setup 32 | ``` 33 | 34 | 3. Run the development server: 35 | ```bash 36 | bun run dev 37 | ``` 38 | 39 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 40 | 41 | ## Cloudflare Integration 42 | 43 | Besides the `dev` script, `c3` has added extra scripts for Cloudflare Pages integration: 44 | - `pages:build`: Build the application for Pages using [`@cloudflare/next-on-pages`](https://github.com/cloudflare/next-on-pages) CLI 45 | - `preview`: Locally preview your Pages application using [Wrangler](https://developers.cloudflare.com/workers/wrangler/) CLI 46 | - `deploy`: Deploy your Pages application using Wrangler CLI 47 | - `cf-typegen`: Generate typescript types for Cloudflare env. 48 | 49 | > __Note:__ While the `dev` script is optimal for local development, you should preview your Pages application periodically to ensure it works properly in the Pages environment. 50 | 51 | ## Bindings 52 | 53 | Cloudflare [Bindings](https://developers.cloudflare.com/pages/functions/bindings/) allow you to interact with Cloudflare Platform resources. You can use bindings during development, local preview, and in the deployed application. 54 | 55 | For detailed instructions on setting up bindings, refer to the Cloudflare documentation. 56 | 57 | ## Database Migrations 58 | Quick explaination of D1 set up: 59 | - D1 is a serverless database that follows SQLite convention. 60 | - Within Cloudflare pages and workers, you can directly query d1 with [client api](https://developers.cloudflare.com/d1/build-with-d1/d1-client-api/) exposed by bindings (eg. `env.BINDING`) 61 | - You can also query d1 via [rest api](https://developers.cloudflare.com/api/operations/cloudflare-d1-create-database) 62 | - Locally, wrangler auto generates sqlite files at `.wrangler/state/v3/d1` after `bun run dev`. 63 | - Local dev environment (`bun run dev`) interact with [local d1 session](https://developers.cloudflare.com/d1/build-with-d1/local-development/#start-a-local-development-session), which is based on some SQlite files located at `.wrangler/state/v3/d1`. 64 | - In dev mode (`bun run db::dev`), Drizzle-kit (migrate and studio) directly modifies these files as regular SQlite db. While `bun run db::prod` use d1-http driver to interact with remote d1 via rest api. Therefore we need to set env var at `.env.example` 65 | 66 | To generate migrations files: 67 | - `bun run db:generate` 68 | 69 | To apply database migrations: 70 | - For development: `bun run db:migrate:dev` 71 | - For production: `bun run db:migrate:prd` 72 | 73 | To inspect database: 74 | - For local database `bun run db:studio:dev` 75 | - For remote database `bun run db:studio:prod` 76 | 77 | ## Cloudflare R2 Bucket CORS / File Upload 78 | 79 | Don't forget to add the CORS policy to the R2 bucket. The CORS policy should look like this: 80 | 81 | ```json 82 | [ 83 | { 84 | "AllowedOrigins": [ 85 | "http://localhost:3000", 86 | "https://your-domain.com" 87 | ], 88 | "AllowedMethods": [ 89 | "GET", 90 | "PUT" 91 | ], 92 | "AllowedHeaders": [ 93 | "Content-Type" 94 | ], 95 | "ExposeHeaders": [ 96 | "ETag" 97 | ] 98 | } 99 | ] 100 | ``` 101 | 102 | You can now even set up object upload. 103 | 104 | ## Manual Setup 105 | 106 | If you prefer manual setup: 107 | 108 | 1. Create a Cloudflare account and install Wrangler CLI. 109 | 2. Create a D1 database: `bunx wrangler d1 create ${dbName}` 110 | 3. Create a `.dev.vars` file in the project root with your Google OAuth credentials and NextAuth secret. 111 | 1. `AUTH_SECRET`, generate by command `openssl rand -base64 32` or `bunx auth secret` 112 | 2. `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET` for google oauth. 113 | 1. First create [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent). Tips: no wait time if you skip logo upload. 114 | 2. Create [credential](https://console.cloud.google.com/apis/credentials). Put `https://your-domain` and `http://localhost:3000` at "Authorized JavaScript origins". Put `https://your-domain/api/auth/callback/google` and `http://localhost:3000/api/auth/callback/google` at "Authorized redirect URIs". 115 | 4. Generate db migration files: `bun run db:generate` 116 | 5. Run local migration: `bunx wrangler d1 execute ${dbName} --local --file=migrations/0000_setup.sql` or using drizzle `bun run db:migrate:dev` 117 | 6. Run remote migration: `bunx wrangler d1 execute ${dbName} --remote --file=migrations/0000_setup.sql` or using drizzle `bun run db:migrate:prod` 118 | 7. Start development server: `bun run dev` 119 | 8. Deploy: `bun run deploy` 120 | 121 | ## The Beauty of This Stack 122 | 123 | - Fully scalable and composable 124 | - No environment variables needed (use `env.DB`, `env.KV`, `env.Queue`, `env.AI`, etc.) 125 | - Powerful tools like Wrangler for database management and migrations 126 | - Cost-effective scaling (e.g., $5/month for multiple high-traffic projects) 127 | 128 | Just change your Cloudflare account ID in the project settings, and you're good to go! 129 | 130 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermemoryai/cloudflare-saas-stack/600a5b385e30e8a387dce6d0d782b5a6283db872/bun.lockb -------------------------------------------------------------------------------- /cf-env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv extends CloudflareEnv { 4 | } 5 | } 6 | } 7 | 8 | export type {}; -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | 5 | function getLocalD1DB() { 6 | try { 7 | const basePath = path.resolve(".wrangler"); 8 | const dbFile = fs 9 | .readdirSync(basePath, { encoding: "utf-8", recursive: true }) 10 | .find((f) => f.endsWith(".sqlite")); 11 | 12 | if (!dbFile) { 13 | throw new Error(`.sqlite file not found in ${basePath}`); 14 | } 15 | 16 | const url = path.resolve(basePath, dbFile); 17 | return url; 18 | } catch (err) { 19 | console.log(`Error ${err}`); 20 | } 21 | } 22 | 23 | export default defineConfig({ 24 | dialect: "sqlite", 25 | schema: "./src/server/db/schema.ts", 26 | out: "./drizzle", 27 | ...(process.env.NODE_ENV === "production" 28 | ? { 29 | driver: "d1-http", 30 | dbCredentials: { 31 | accountId: process.env.CLOUDFLARE_D1_ACCOUNT_ID, 32 | databaseId: process.env.DATABASE, 33 | token: process.env.CLOUDFLARE_D1_API_TOKEN, 34 | }, 35 | } 36 | : { 37 | dbCredentials: { 38 | url: getLocalD1DB(), 39 | }, 40 | }), 41 | }); -------------------------------------------------------------------------------- /drizzle/0000_setup.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `account` ( 2 | `userId` text NOT NULL, 3 | `type` text NOT NULL, 4 | `provider` text NOT NULL, 5 | `providerAccountId` text NOT NULL, 6 | `refresh_token` text, 7 | `access_token` text, 8 | `expires_at` integer, 9 | `token_type` text, 10 | `scope` text, 11 | `id_token` text, 12 | `session_state` text, 13 | PRIMARY KEY(`provider`, `providerAccountId`), 14 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 15 | ); 16 | --> statement-breakpoint 17 | CREATE TABLE `authenticator` ( 18 | `credentialID` text NOT NULL, 19 | `userId` text NOT NULL, 20 | `providerAccountId` text NOT NULL, 21 | `credentialPublicKey` text NOT NULL, 22 | `counter` integer NOT NULL, 23 | `credentialDeviceType` text NOT NULL, 24 | `credentialBackedUp` integer NOT NULL, 25 | `transports` text, 26 | PRIMARY KEY(`userId`, `credentialID`), 27 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 28 | ); 29 | --> statement-breakpoint 30 | CREATE TABLE `session` ( 31 | `sessionToken` text PRIMARY KEY NOT NULL, 32 | `userId` text NOT NULL, 33 | `expires` integer NOT NULL, 34 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 35 | ); 36 | --> statement-breakpoint 37 | CREATE TABLE `user` ( 38 | `id` text PRIMARY KEY NOT NULL, 39 | `name` text, 40 | `email` text, 41 | `emailVerified` integer, 42 | `image` text 43 | ); 44 | --> statement-breakpoint 45 | CREATE TABLE `verificationToken` ( 46 | `identifier` text NOT NULL, 47 | `token` text NOT NULL, 48 | `expires` integer NOT NULL, 49 | PRIMARY KEY(`identifier`, `token`) 50 | ); 51 | --> statement-breakpoint 52 | CREATE UNIQUE INDEX `authenticator_credentialID_unique` ON `authenticator` (`credentialID`);--> statement-breakpoint 53 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "2f18b018-47bf-43bc-bff8-c0dac7580d47", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "account": { 8 | "name": "account", 9 | "columns": { 10 | "userId": { 11 | "name": "userId", 12 | "type": "text", 13 | "primaryKey": false, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "type": { 18 | "name": "type", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "provider": { 25 | "name": "provider", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "providerAccountId": { 32 | "name": "providerAccountId", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "refresh_token": { 39 | "name": "refresh_token", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "access_token": { 46 | "name": "access_token", 47 | "type": "text", 48 | "primaryKey": false, 49 | "notNull": false, 50 | "autoincrement": false 51 | }, 52 | "expires_at": { 53 | "name": "expires_at", 54 | "type": "integer", 55 | "primaryKey": false, 56 | "notNull": false, 57 | "autoincrement": false 58 | }, 59 | "token_type": { 60 | "name": "token_type", 61 | "type": "text", 62 | "primaryKey": false, 63 | "notNull": false, 64 | "autoincrement": false 65 | }, 66 | "scope": { 67 | "name": "scope", 68 | "type": "text", 69 | "primaryKey": false, 70 | "notNull": false, 71 | "autoincrement": false 72 | }, 73 | "id_token": { 74 | "name": "id_token", 75 | "type": "text", 76 | "primaryKey": false, 77 | "notNull": false, 78 | "autoincrement": false 79 | }, 80 | "session_state": { 81 | "name": "session_state", 82 | "type": "text", 83 | "primaryKey": false, 84 | "notNull": false, 85 | "autoincrement": false 86 | } 87 | }, 88 | "indexes": {}, 89 | "foreignKeys": { 90 | "account_userId_user_id_fk": { 91 | "name": "account_userId_user_id_fk", 92 | "tableFrom": "account", 93 | "tableTo": "user", 94 | "columnsFrom": [ 95 | "userId" 96 | ], 97 | "columnsTo": [ 98 | "id" 99 | ], 100 | "onDelete": "cascade", 101 | "onUpdate": "no action" 102 | } 103 | }, 104 | "compositePrimaryKeys": { 105 | "account_provider_providerAccountId_pk": { 106 | "columns": [ 107 | "provider", 108 | "providerAccountId" 109 | ], 110 | "name": "account_provider_providerAccountId_pk" 111 | } 112 | }, 113 | "uniqueConstraints": {} 114 | }, 115 | "authenticator": { 116 | "name": "authenticator", 117 | "columns": { 118 | "credentialID": { 119 | "name": "credentialID", 120 | "type": "text", 121 | "primaryKey": false, 122 | "notNull": true, 123 | "autoincrement": false 124 | }, 125 | "userId": { 126 | "name": "userId", 127 | "type": "text", 128 | "primaryKey": false, 129 | "notNull": true, 130 | "autoincrement": false 131 | }, 132 | "providerAccountId": { 133 | "name": "providerAccountId", 134 | "type": "text", 135 | "primaryKey": false, 136 | "notNull": true, 137 | "autoincrement": false 138 | }, 139 | "credentialPublicKey": { 140 | "name": "credentialPublicKey", 141 | "type": "text", 142 | "primaryKey": false, 143 | "notNull": true, 144 | "autoincrement": false 145 | }, 146 | "counter": { 147 | "name": "counter", 148 | "type": "integer", 149 | "primaryKey": false, 150 | "notNull": true, 151 | "autoincrement": false 152 | }, 153 | "credentialDeviceType": { 154 | "name": "credentialDeviceType", 155 | "type": "text", 156 | "primaryKey": false, 157 | "notNull": true, 158 | "autoincrement": false 159 | }, 160 | "credentialBackedUp": { 161 | "name": "credentialBackedUp", 162 | "type": "integer", 163 | "primaryKey": false, 164 | "notNull": true, 165 | "autoincrement": false 166 | }, 167 | "transports": { 168 | "name": "transports", 169 | "type": "text", 170 | "primaryKey": false, 171 | "notNull": false, 172 | "autoincrement": false 173 | } 174 | }, 175 | "indexes": { 176 | "authenticator_credentialID_unique": { 177 | "name": "authenticator_credentialID_unique", 178 | "columns": [ 179 | "credentialID" 180 | ], 181 | "isUnique": true 182 | } 183 | }, 184 | "foreignKeys": { 185 | "authenticator_userId_user_id_fk": { 186 | "name": "authenticator_userId_user_id_fk", 187 | "tableFrom": "authenticator", 188 | "tableTo": "user", 189 | "columnsFrom": [ 190 | "userId" 191 | ], 192 | "columnsTo": [ 193 | "id" 194 | ], 195 | "onDelete": "cascade", 196 | "onUpdate": "no action" 197 | } 198 | }, 199 | "compositePrimaryKeys": { 200 | "authenticator_userId_credentialID_pk": { 201 | "columns": [ 202 | "userId", 203 | "credentialID" 204 | ], 205 | "name": "authenticator_userId_credentialID_pk" 206 | } 207 | }, 208 | "uniqueConstraints": {} 209 | }, 210 | "session": { 211 | "name": "session", 212 | "columns": { 213 | "sessionToken": { 214 | "name": "sessionToken", 215 | "type": "text", 216 | "primaryKey": true, 217 | "notNull": true, 218 | "autoincrement": false 219 | }, 220 | "userId": { 221 | "name": "userId", 222 | "type": "text", 223 | "primaryKey": false, 224 | "notNull": true, 225 | "autoincrement": false 226 | }, 227 | "expires": { 228 | "name": "expires", 229 | "type": "integer", 230 | "primaryKey": false, 231 | "notNull": true, 232 | "autoincrement": false 233 | } 234 | }, 235 | "indexes": {}, 236 | "foreignKeys": { 237 | "session_userId_user_id_fk": { 238 | "name": "session_userId_user_id_fk", 239 | "tableFrom": "session", 240 | "tableTo": "user", 241 | "columnsFrom": [ 242 | "userId" 243 | ], 244 | "columnsTo": [ 245 | "id" 246 | ], 247 | "onDelete": "cascade", 248 | "onUpdate": "no action" 249 | } 250 | }, 251 | "compositePrimaryKeys": {}, 252 | "uniqueConstraints": {} 253 | }, 254 | "user": { 255 | "name": "user", 256 | "columns": { 257 | "id": { 258 | "name": "id", 259 | "type": "text", 260 | "primaryKey": true, 261 | "notNull": true, 262 | "autoincrement": false 263 | }, 264 | "name": { 265 | "name": "name", 266 | "type": "text", 267 | "primaryKey": false, 268 | "notNull": false, 269 | "autoincrement": false 270 | }, 271 | "email": { 272 | "name": "email", 273 | "type": "text", 274 | "primaryKey": false, 275 | "notNull": false, 276 | "autoincrement": false 277 | }, 278 | "emailVerified": { 279 | "name": "emailVerified", 280 | "type": "integer", 281 | "primaryKey": false, 282 | "notNull": false, 283 | "autoincrement": false 284 | }, 285 | "image": { 286 | "name": "image", 287 | "type": "text", 288 | "primaryKey": false, 289 | "notNull": false, 290 | "autoincrement": false 291 | } 292 | }, 293 | "indexes": { 294 | "user_email_unique": { 295 | "name": "user_email_unique", 296 | "columns": [ 297 | "email" 298 | ], 299 | "isUnique": true 300 | } 301 | }, 302 | "foreignKeys": {}, 303 | "compositePrimaryKeys": {}, 304 | "uniqueConstraints": {} 305 | }, 306 | "verificationToken": { 307 | "name": "verificationToken", 308 | "columns": { 309 | "identifier": { 310 | "name": "identifier", 311 | "type": "text", 312 | "primaryKey": false, 313 | "notNull": true, 314 | "autoincrement": false 315 | }, 316 | "token": { 317 | "name": "token", 318 | "type": "text", 319 | "primaryKey": false, 320 | "notNull": true, 321 | "autoincrement": false 322 | }, 323 | "expires": { 324 | "name": "expires", 325 | "type": "integer", 326 | "primaryKey": false, 327 | "notNull": true, 328 | "autoincrement": false 329 | } 330 | }, 331 | "indexes": {}, 332 | "foreignKeys": {}, 333 | "compositePrimaryKeys": { 334 | "verificationToken_identifier_token_pk": { 335 | "columns": [ 336 | "identifier", 337 | "token" 338 | ], 339 | "name": "verificationToken_identifier_token_pk" 340 | } 341 | }, 342 | "uniqueConstraints": {} 343 | } 344 | }, 345 | "enums": {}, 346 | "_meta": { 347 | "schemas": {}, 348 | "tables": {}, 349 | "columns": {} 350 | }, 351 | "internal": { 352 | "indexes": {} 353 | } 354 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1725503601816, 9 | "tag": "0000_setup", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler on Wed Sep 04 2024 11:25:36 GMT-0700 (Mountain Standard Time) 2 | // by running `wrangler types --env-interface CloudflareEnv env.d.ts` 3 | 4 | interface CloudflareEnv { 5 | DATABASE: D1Database; 6 | } 7 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev'; 2 | 3 | // Here we use the @cloudflare/next-on-pages next-dev module to allow us to use bindings during local development 4 | // (when running the application with `next dev`), for more information see: 5 | // https://github.com/cloudflare/next-on-pages/blob/main/internal-packages/next-dev/README.md 6 | if (process.env.NODE_ENV === 'development') { 7 | await setupDevPlatform(); 8 | } 9 | 10 | /** @type {import('next').NextConfig} */ 11 | const nextConfig = {}; 12 | 13 | export default nextConfig; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-saas-stack", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "pages:build": "bunx @cloudflare/next-on-pages", 11 | "preview": "bun pages:build && wrangler pages dev", 12 | "deploy": "bun pages:build && wrangler pages deploy", 13 | "cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts", 14 | "setup": "bun run scripts/setup.ts", 15 | "db:generate": "drizzle-kit generate", 16 | "db:migrate:dev": "drizzle-kit migrate", 17 | "db:migrate:prod": "NODE_ENV=production drizzle-kit migrate", 18 | "db:studio:dev": "drizzle-kit studio", 19 | "db:studio:prod": "NODE_ENV=production drizzle-kit studio" 20 | }, 21 | "dependencies": { 22 | "@auth/drizzle-adapter": "^1.4.2", 23 | "@clack/prompts": "^0.7.0", 24 | "@iarna/toml": "^2.2.5", 25 | "@libsql/client": "^0.10.0", 26 | "@radix-ui/react-icons": "^1.3.0", 27 | "@radix-ui/react-slot": "^1.1.0", 28 | "@t3-oss/env-nextjs": "^0.11.1", 29 | "class-variance-authority": "^0.7.0", 30 | "clsx": "^2.1.1", 31 | "drizzle-kit": "^0.24.2", 32 | "drizzle-orm": "^0.33.0", 33 | "lucide-react": "^0.438.0", 34 | "next": "14.2.5", 35 | "next-auth": "^5.0.0-beta.20", 36 | "react": "^18", 37 | "react-dom": "^18", 38 | "tailwind-merge": "^2.5.2", 39 | "tailwindcss-animate": "^1.0.7", 40 | "zod": "^3.23.8" 41 | }, 42 | "devDependencies": { 43 | "@cloudflare/next-on-pages": "1", 44 | "@cloudflare/workers-types": "^4.20240903.0", 45 | "@types/node": "^20", 46 | "@types/react": "^18", 47 | "@types/react-dom": "^18", 48 | "eslint": "^8", 49 | "eslint-config-next": "14.2.5", 50 | "eslint-plugin-next-on-pages": "^1.13.2", 51 | "postcss": "^8", 52 | "tailwindcss": "^3.4.1", 53 | "typescript": "^5", 54 | "vercel": "^37.3.0", 55 | "wrangler": "^3.74.0" 56 | } 57 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/setup.ts: -------------------------------------------------------------------------------- 1 | import { execSync, spawnSync } from "node:child_process"; 2 | import crypto from "node:crypto"; 3 | import { default as fs } from "node:fs"; 4 | import os from "node:os"; 5 | import { default as path } from "node:path"; 6 | import { cancel, intro, outro, select, spinner, text } from "@clack/prompts"; 7 | import { default as toml } from "@iarna/toml"; 8 | 9 | // Function to execute shell commands 10 | function executeCommand(command: string) { 11 | console.log(`\x1b[33m${command}\x1b[0m`); 12 | try { 13 | return execSync(command, { encoding: "utf-8" }); 14 | } catch (error: any) { 15 | return { error: true, message: error.stdout || error.stderr }; 16 | } 17 | } 18 | 19 | // Function to prompt user for input without readline-sync 20 | async function prompt(message: string, defaultValue: string): Promise { 21 | return (await text({ 22 | message: `${message} (${defaultValue}):`, 23 | placeholder: defaultValue, 24 | defaultValue, 25 | })) as string; 26 | } 27 | 28 | // Function to extract account IDs from `wrangler whoami` output 29 | function extractAccountDetails(output: string): { name: string; id: string }[] { 30 | const lines = output.split("\n"); 31 | const accountDetails: { name: string; id: string }[] = []; 32 | 33 | for (const line of lines) { 34 | const isValidLine = 35 | line.trim().startsWith("│ ") && line.trim().endsWith(" │"); 36 | 37 | if (isValidLine) { 38 | const regex = /\b[a-f0-9]{32}\b/g; 39 | const matches = line.match(regex); 40 | 41 | if (matches && matches.length === 1) { 42 | const accountName = line.split("│ ")[1]?.trim(); 43 | const accountId = matches[0].replace("│ ", "").replace(" │", ""); 44 | if (accountName === undefined || accountId === undefined) { 45 | console.error( 46 | "\x1b[31mError extracting account details from wrangler whoami output.\x1b[0m", 47 | ); 48 | cancel("Operation cancelled."); 49 | process.exit(1); 50 | } 51 | accountDetails.push({ name: accountName, id: accountId }); 52 | } 53 | } 54 | } 55 | 56 | return accountDetails; 57 | } 58 | 59 | // Function to prompt for account ID if there are multiple accounts 60 | async function promptForAccountId( 61 | accounts: { name: string; id: string }[], 62 | ): Promise { 63 | if (accounts.length === 1) { 64 | if (!accounts[0]) { 65 | console.error( 66 | "\x1b[31mNo accounts found. Please run `wrangler login`.\x1b[0m", 67 | ); 68 | cancel("Operation cancelled."); 69 | process.exit(1); 70 | } 71 | if (!accounts[0].id) { 72 | console.error( 73 | "\x1b[31mNo accounts found. Please run `wrangler login`.\x1b[0m", 74 | ); 75 | cancel("Operation cancelled."); 76 | process.exit(1); 77 | } 78 | return accounts[0].id; 79 | } else if (accounts.length > 1) { 80 | const options = accounts.map((account) => ({ 81 | value: account.id, 82 | label: account.name, 83 | })); 84 | const selectedAccountId = await select({ 85 | message: "Select an account to use:", 86 | options, 87 | }); 88 | 89 | return selectedAccountId as string; 90 | } else { 91 | console.error( 92 | "\x1b[31mNo accounts found. Please run `wrangler login`.\x1b[0m", 93 | ); 94 | cancel("Operation cancelled."); 95 | process.exit(1); 96 | } 97 | } 98 | 99 | let pagesName: string; 100 | let bucketR2Name: string; 101 | let dbName: string; 102 | 103 | // Function to create database and update wrangler.toml 104 | async function createDatabaseAndConfigure() { 105 | intro(`Let's set up your database...`); 106 | const defaultDBName = `${path.basename(process.cwd())}-db`; 107 | dbName = await prompt("Enter the name of your database", defaultDBName); 108 | 109 | let databaseID: string; 110 | 111 | const wranglerTomlPath = path.join(__dirname, "..", "wrangler.toml"); 112 | let wranglerToml: toml.JsonMap; 113 | 114 | try { 115 | const wranglerTomlContent = fs.readFileSync(wranglerTomlPath, "utf-8"); 116 | wranglerToml = toml.parse(wranglerTomlContent); 117 | } catch (error) { 118 | console.error("\x1b[31mError reading wrangler.toml:", error, "\x1b[0m"); 119 | cancel("Operation cancelled."); 120 | } 121 | 122 | // Run command to create a new database 123 | const creationOutput = executeCommand(`bunx wrangler d1 create ${dbName}`); 124 | 125 | if (creationOutput === undefined || typeof creationOutput !== "string") { 126 | console.log( 127 | "\x1b[33mDatabase creation failed, maybe you have already created a database with that name. I'll try to find the database ID for you.\x1b[0m", 128 | ); 129 | const dbInfoOutput = executeCommand(`bunx wrangler d1 info ${dbName}`); 130 | const getInfo = (dbInfoOutput as string).match( 131 | /│ [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} │/i, 132 | ); 133 | if (getInfo && getInfo.length === 1) { 134 | console.log( 135 | "\x1b[33mFound it! The database ID is: ", 136 | getInfo[0], 137 | "\x1b[0m", 138 | ); 139 | databaseID = getInfo[0].replace("│ ", "").replace(" │", ""); 140 | } else { 141 | console.error( 142 | "\x1b[31mSomething went wrong when initialising the database. Please try again.\x1b[0m", 143 | ); 144 | cancel("Operation cancelled."); 145 | } 146 | } else { 147 | // Extract database ID from the output 148 | const matchResult = (creationOutput as string).match( 149 | /database_id = "(.*)"/, 150 | ); 151 | if (matchResult && matchResult.length === 2 && matchResult !== undefined) { 152 | databaseID = matchResult[1]!; 153 | } else { 154 | console.error("Failed to extract database ID from the output."); 155 | cancel("Operation cancelled."); 156 | } 157 | } 158 | 159 | // Update wrangler.toml with database configuration 160 | wranglerToml = { 161 | ...wranglerToml!, 162 | d1_databases: [ 163 | { 164 | binding: "DATABASE", 165 | database_name: dbName, 166 | database_id: databaseID!, 167 | migrations_dir: "./drizzle", 168 | }, 169 | ], 170 | }; 171 | 172 | try { 173 | const updatedToml = toml.stringify(wranglerToml); 174 | fs.writeFileSync(wranglerTomlPath, updatedToml); 175 | console.log( 176 | "\x1b[33mDatabase configuration updated in wrangler.toml\x1b[0m", 177 | ); 178 | } catch (error) { 179 | console.error("\x1b[31mError updating wrangler.toml:", error, "\x1b[0m"); 180 | cancel("Operation cancelled."); 181 | } 182 | 183 | outro("Database configuration completed."); 184 | } 185 | 186 | async function createPagesProject() { 187 | const pagesProjectSpinner = spinner(); 188 | const defualtPagesName = path.basename(process.cwd()); 189 | pagesName = await prompt( 190 | "Enter the name of your cloudflare pages", 191 | defualtPagesName, 192 | ); 193 | pagesProjectSpinner.start("Creating Pages project..."); 194 | const branch = executeCommand("git branch --show-current"); 195 | executeCommand( 196 | `wrangler pages project create ${pagesName} --production-branch ${branch}`, 197 | ); 198 | pagesProjectSpinner.stop("Pages project created."); 199 | } 200 | 201 | async function createBucketR2() { 202 | const wranglerTomlPath = path.join(__dirname, "..", "wrangler.toml"); 203 | let wranglerToml: toml.JsonMap; 204 | 205 | try { 206 | const wranglerTomlContent = fs.readFileSync(wranglerTomlPath, "utf-8"); 207 | wranglerToml = toml.parse(wranglerTomlContent); 208 | } catch (error) { 209 | console.error("\x1b[31mError reading wrangler.toml:", error, "\x1b[0m"); 210 | cancel("Operation cancelled."); 211 | } 212 | 213 | const bucketR2Spinner = spinner(); 214 | const defaultBucketName = `${path.basename(process.cwd())}-bucket`; 215 | bucketR2Name = await prompt( 216 | "Enter the name of your bucket", 217 | defaultBucketName, 218 | ); 219 | bucketR2Spinner.start("Creating bucket..."); 220 | executeCommand(`wrangler r2 bucket create ${bucketR2Name}`); 221 | bucketR2Spinner.stop("Bucket created."); 222 | 223 | // Update wrangler.toml with bucket configuration 224 | wranglerToml = { 225 | ...wranglerToml!, 226 | r2_buckets: [ 227 | { 228 | binding: "MY_BUCKET", 229 | bucket_name: bucketR2Name, 230 | }, 231 | ], 232 | }; 233 | 234 | try { 235 | const updatedToml = toml.stringify(wranglerToml); 236 | fs.writeFileSync(wranglerTomlPath, updatedToml); 237 | console.log( 238 | "\x1b[33mBucket configuration updated in wrangler.toml\x1b[0m", 239 | ); 240 | } catch (error) { 241 | console.error("\x1b[31mError updating wrangler.toml:", error, "\x1b[0m"); 242 | cancel("Operation cancelled."); 243 | } 244 | 245 | outro("Bucket configuration completed."); 246 | } 247 | 248 | // Function to prompt for Google client credentials 249 | async function promptForGoogleClientCredentials() { 250 | intro("Now, time for auth!"); 251 | 252 | const devVarsPath = path.join(__dirname, "..", ".dev.vars"); 253 | 254 | if (!fs.existsSync(devVarsPath)) { 255 | console.log( 256 | "\x1b[33mNow, we will set up authentication for your app using Google OAuth2. \nGo to https://console.cloud.google.com/, create a new project and set up OAuth consent screen.\nThen, go to Credentials > OAuth client ID and create a new client ID.\nPaste the client ID and client secret below. \n\nMore info: https://developers.google.com/workspace/guides/configure-oauth-consent#:~:text=Go%20to%20OAuth%20consent%20screen,sensitive%20scopes%2C%20and%20restricted%20scopes.\x1b[0m", 257 | ); 258 | const clientId = await prompt( 259 | "Enter your Google Client ID (enter to skip)", 260 | "", 261 | ); 262 | const clientSecret = await prompt( 263 | "Enter your Google Client Secret (enter to skip)", 264 | "", 265 | ); 266 | 267 | try { 268 | fs.writeFileSync( 269 | devVarsPath, 270 | `AUTH_GOOGLE_ID=${clientId}\nAUTH_GOOGLE_SECRET=${clientSecret}\n`, 271 | ); 272 | console.log( 273 | "\x1b[33m.dev.vars file created with Google Client ID and Client Secret.\x1b[0m", 274 | ); 275 | } catch (error) { 276 | console.error("\x1b[31mError creating .dev.vars file:", error, "\x1b[0m"); 277 | cancel("Operation cancelled."); 278 | } 279 | } else { 280 | console.log( 281 | "\x1b[31m.dev.vars file already exists. Skipping creation.\x1b[0m", 282 | ); 283 | } 284 | 285 | outro(".dev.vars updated with Google Client ID and Client Secret."); 286 | } 287 | 288 | // Function to generate secure random 32-character string 289 | function generateSecureRandomString(length: number): string { 290 | return crypto 291 | .randomBytes(Math.ceil(length / 2)) 292 | .toString("hex") 293 | .slice(0, length); 294 | } 295 | 296 | // Function to update .dev.vars with secure random string 297 | async function updateDevVarsWithSecret() { 298 | const secret = generateSecureRandomString(32); 299 | const devVarsPath = path.join(__dirname, "..", ".dev.vars"); 300 | 301 | try { 302 | if (!fs.readFileSync(devVarsPath, "utf-8").includes("AUTH_SECRET")) { 303 | fs.appendFileSync(devVarsPath, `\nAUTH_SECRET=${secret}`); 304 | console.log("\x1b[33mSecret appended to .dev.vars file.\x1b[0m"); 305 | } else { 306 | console.log("\x1b[31mAUTH_SECRET already exists in .dev.vars\x1b[0m"); 307 | } 308 | } catch (error) { 309 | console.error("\x1b[31mError updating .dev.vars file:", error, "\x1b[0m"); 310 | cancel("Operation cancelled."); 311 | } 312 | 313 | outro(".dev.vars updated with secure secret."); 314 | } 315 | 316 | // Function to run database migrations 317 | async function runDatabaseMigrations(dbName: string) { 318 | const setupMigrationSpinner = spinner(); 319 | setupMigrationSpinner.start("Generating setup migration..."); 320 | executeCommand("bunx drizzle-kit generate --name setup"); 321 | setupMigrationSpinner.stop("Setup migration generated."); 322 | 323 | const localMigrationSpinner = spinner(); 324 | localMigrationSpinner.start("Running local database migrations..."); 325 | executeCommand(`bunx wrangler d1 migrations apply ${dbName}`); 326 | localMigrationSpinner.stop("Local database migrations completed."); 327 | 328 | const remoteMigrationSpinner = spinner(); 329 | remoteMigrationSpinner.start("Running remote database migrations..."); 330 | executeCommand(`bunx wrangler d1 migrations apply ${dbName} --remote`); 331 | remoteMigrationSpinner.stop("Remote database migrations completed."); 332 | } 333 | 334 | function setEnvironmentVariable(name: string, value: string) { 335 | const platform = os.platform(); 336 | let command: string; 337 | 338 | if (platform === "win32") { 339 | command = `set ${name}=${value}`; // Windows Command Prompt 340 | } else { 341 | command = `export ${name}=${value}`; // Unix-like shells 342 | } 343 | 344 | console.log( 345 | `\x1b[33mPlease run this command: ${command} and then rerun the setup script.\x1b[0m`, 346 | ); 347 | process.exit(1); 348 | } 349 | 350 | async function main() { 351 | try { 352 | const whoamiOutput = executeCommand("wrangler whoami"); 353 | if (whoamiOutput === undefined || typeof whoamiOutput !== "string") { 354 | console.error( 355 | "\x1b[31mError running wrangler whoami. Please run `wrangler login`.\x1b[0m", 356 | ); 357 | cancel("Operation cancelled."); 358 | process.exit(1); 359 | } 360 | 361 | try { 362 | await createDatabaseAndConfigure(); 363 | } catch (error) { 364 | console.error("\x1b[31mError:", error, "\x1b[0m"); 365 | const accountIds = extractAccountDetails(whoamiOutput); 366 | const accountId = await promptForAccountId(accountIds); 367 | setEnvironmentVariable("CLOUDFLARE_ACCOUNT_ID", accountId); 368 | cancel("Operation cancelled."); 369 | process.exit(1); 370 | } 371 | 372 | try { 373 | await createPagesProject(); 374 | } catch (error) { 375 | console.error("\x1b[31mError:", error, "\x1b[0m"); 376 | cancel("Operation cancelled."); 377 | process.exit(1); 378 | } 379 | 380 | // try { 381 | // await createBucketR2(); 382 | // } catch (error) { 383 | // console.error("\x1b[31mError:", error, "\x1b[0m"); 384 | // cancel("Operation cancelled."); 385 | // process.exit(1); 386 | // } 387 | 388 | await promptForGoogleClientCredentials(); 389 | console.log("\x1b[33mReady... Set... Launch\x1b[0m"); 390 | await updateDevVarsWithSecret(); 391 | await runDatabaseMigrations(dbName); 392 | 393 | console.log("\x1b[33mRunning bun run dev command...\x1b[0m"); 394 | spawnSync("bun", ["run", "dev"], { stdio: "inherit" }); 395 | } catch (error) { 396 | console.error("\x1b[31mError:", error, "\x1b[0m"); 397 | cancel("Operation cancelled."); 398 | } 399 | } 400 | 401 | main(); 402 | -------------------------------------------------------------------------------- /src/app/api/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/server/auth"; 2 | export const runtime = "edge"; 3 | -------------------------------------------------------------------------------- /src/app/api/hello/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | import { getRequestContext } from '@cloudflare/next-on-pages' 3 | 4 | export const runtime = 'edge' 5 | 6 | export async function GET(request: NextRequest) { 7 | let responseText = 'Hello World' 8 | 9 | // In the edge runtime you can use Bindings that are available in your application 10 | // (for more details see: 11 | // - https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/#use-bindings-in-your-nextjs-application 12 | // - https://developers.cloudflare.com/pages/functions/bindings/ 13 | // ) 14 | // 15 | // KV Example: 16 | // const myKv = getRequestContext().env.MY_KV_NAMESPACE 17 | // await myKv.put('suffix', ' from a KV store!') 18 | // const suffix = await myKv.get('suffix') 19 | // responseText += suffix 20 | 21 | return new Response(responseText) 22 | } 23 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermemoryai/cloudflare-saas-stack/600a5b385e30e8a387dce6d0d782b5a6283db872/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | @layer utilities { 7 | .text-balance { 8 | text-wrap: balance; 9 | } 10 | } 11 | 12 | @layer base { 13 | :root { 14 | --background: 0 0% 100%; 15 | --foreground: 0 0% 3.9%; 16 | --card: 0 0% 100%; 17 | --card-foreground: 0 0% 3.9%; 18 | --popover: 0 0% 100%; 19 | --popover-foreground: 0 0% 3.9%; 20 | --primary: 0 0% 9%; 21 | --primary-foreground: 0 0% 98%; 22 | --secondary: 0 0% 96.1%; 23 | --secondary-foreground: 0 0% 9%; 24 | --muted: 0 0% 96.1%; 25 | --muted-foreground: 0 0% 45.1%; 26 | --accent: 0 0% 96.1%; 27 | --accent-foreground: 0 0% 9%; 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | --border: 0 0% 89.8%; 31 | --input: 0 0% 89.8%; 32 | --ring: 0 0% 3.9%; 33 | --chart-1: 12 76% 61%; 34 | --chart-2: 173 58% 39%; 35 | --chart-3: 197 37% 24%; 36 | --chart-4: 43 74% 66%; 37 | --chart-5: 27 87% 67%; 38 | --radius: 0.5rem; 39 | } 40 | 41 | .dark, [data-theme='dark'] { 42 | --background: 0 0% 3.9%; 43 | --foreground: 0 0% 98%; 44 | --card: 0 0% 3.9%; 45 | --card-foreground: 0 0% 98%; 46 | --popover: 0 0% 3.9%; 47 | --popover-foreground: 0 0% 98%; 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 0 0% 9%; 50 | --secondary: 0 0% 14.9%; 51 | --secondary-foreground: 0 0% 98%; 52 | --muted: 0 0% 14.9%; 53 | --muted-foreground: 0 0% 63.9%; 54 | --accent: 0 0% 14.9%; 55 | --accent-foreground: 0 0% 98%; 56 | --destructive: 0 62.8% 30.6%; 57 | --destructive-foreground: 0 0% 98%; 58 | --border: 0 0% 14.9%; 59 | --input: 0 0% 14.9%; 60 | --ring: 0 0% 83.1%; 61 | --chart-1: 220 70% 50%; 62 | --chart-2: 160 60% 45%; 63 | --chart-3: 30 80% 55%; 64 | --chart-4: 280 65% 60%; 65 | --chart-5: 340 75% 55%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | 74 | body { 75 | @apply bg-background text-foreground; 76 | } 77 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ThemeScript } from "@/lib/theme/theme-script"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "Create Next App", 10 | description: "Generated by create next app", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode; 17 | }>) { 18 | return ( 19 | 20 | 21 | 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | export const runtime = "edge"; 2 | 3 | export default function NotFound() { 4 | return ( 5 | <> 6 | 404: This page could not be found. 7 |
8 |
9 |