├── .devcontainer ├── Dockerfile ├── compose.dev.yml └── devcontainer.json ├── .env.example ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── check.yaml ├── .gitignore ├── LICENSE ├── README.md ├── components.json ├── drizzle.config.ts ├── next.config.js ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.cjs ├── prettier.config.js ├── src ├── app │ ├── (auth) │ │ ├── layout.tsx │ │ ├── login │ │ │ ├── discord │ │ │ │ ├── callback │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── login.tsx │ │ │ └── page.tsx │ │ ├── reset-password │ │ │ ├── [token] │ │ │ │ ├── page.tsx │ │ │ │ └── reset-password.tsx │ │ │ ├── page.tsx │ │ │ └── send-reset-email.tsx │ │ ├── signup │ │ │ ├── page.tsx │ │ │ └── signup.tsx │ │ └── verify-email │ │ │ ├── page.tsx │ │ │ └── verify-code.tsx │ ├── (landing) │ │ ├── _components │ │ │ ├── copy-to-clipboard.tsx │ │ │ ├── feature-icons.tsx │ │ │ ├── footer.tsx │ │ │ ├── header.tsx │ │ │ └── hover-card.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── (main) │ │ ├── _components │ │ │ ├── footer.tsx │ │ │ ├── header.tsx │ │ │ └── user-dropdown.tsx │ │ ├── account │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── _components │ │ │ │ ├── dashboard-nav.tsx │ │ │ │ ├── new-post.tsx │ │ │ │ ├── post-card-skeleton.tsx │ │ │ │ ├── post-card.tsx │ │ │ │ ├── posts-skeleton.tsx │ │ │ │ ├── posts.tsx │ │ │ │ └── verificiation-warning.tsx │ │ │ ├── billing │ │ │ │ ├── _components │ │ │ │ │ ├── billing-skeleton.tsx │ │ │ │ │ ├── billing.tsx │ │ │ │ │ └── manage-subscription-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── settings │ │ │ │ └── page.tsx │ │ ├── editor │ │ │ └── [postId] │ │ │ │ ├── _components │ │ │ │ ├── post-editor.tsx │ │ │ │ └── post-preview.tsx │ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── api │ │ ├── trpc │ │ │ └── [trpc] │ │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── stripe │ │ │ └── route.ts │ ├── icon.tsx │ ├── layout.tsx │ ├── robots.ts │ └── sitemap.ts ├── components │ ├── icons.tsx │ ├── loading-button.tsx │ ├── password-input.tsx │ ├── responsive-dialog.tsx │ ├── submit-button.tsx │ ├── theme-provider.tsx │ ├── theme-toggle.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── pagination.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── tabs.tsx │ │ └── textarea.tsx ├── config │ └── subscriptions.ts ├── env.js ├── lib │ ├── auth │ │ ├── actions.ts │ │ ├── index.ts │ │ └── validate-request.ts │ ├── constants.ts │ ├── email │ │ ├── index.tsx │ │ └── templates │ │ │ ├── email-verification.tsx │ │ │ └── reset-password.tsx │ ├── fonts.ts │ ├── hooks │ │ ├── use-debounce.ts │ │ └── use-media-query.ts │ ├── logger.ts │ ├── stripe.ts │ ├── utils.ts │ └── validators │ │ └── auth.ts ├── middleware.ts ├── server │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ ├── post │ │ │ │ ├── post.input.ts │ │ │ │ ├── post.procedure.ts │ │ │ │ └── post.service.ts │ │ │ ├── stripe │ │ │ │ ├── stripe.input.ts │ │ │ │ ├── stripe.procedure.ts │ │ │ │ └── stripe.service.ts │ │ │ └── user │ │ │ │ └── user.procedure.ts │ │ └── trpc.ts │ └── db │ │ ├── index.ts │ │ └── schema.ts ├── styles │ └── globals.css └── trpc │ ├── react.tsx │ ├── server.ts │ └── shared.ts ├── tailwind.config.ts ├── tests └── e2e │ ├── auth-with-credential.spec.ts │ └── utils.ts └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/javascript-node:20 2 | -------------------------------------------------------------------------------- /.devcontainer/compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | workspace: 3 | build: 4 | dockerfile: Dockerfile 5 | volumes: 6 | - ../:/workspace:cached 7 | command: /bin/sh -c "while sleep 1000; do :; done" 8 | depends_on: 9 | - database 10 | 11 | database: 12 | image: postgres:17.2-alpine 13 | environment: 14 | POSTGRES_DB: acme 15 | POSTGRES_USER: postgres 16 | POSTGRES_PASSWORD: root 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Next.js Auth Template", 3 | "dockerComposeFile": ["compose.dev.yml"], 4 | "service": "workspace", 5 | "workspaceFolder": "/workspace", 6 | "postCreateCommand": "pnpm config set store-dir $HOME/.pnpm-store", 7 | "postStartCommand": "pnpm install", 8 | "forwardPorts": [3000], 9 | "features": { 10 | "ghcr.io/devcontainers-extra/features/pnpm": "latest" 11 | }, 12 | "customizations": { 13 | "vscode": { 14 | "settings": { 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll.eslint": "explicit", 17 | "source.organizeImports": "explicit", 18 | "source.removeUnusedImports": "explicit" 19 | }, 20 | "editor.guides.bracketPairs": "active", 21 | "editor.rulers": [100], 22 | "typescript.tsdk": "node_modules/typescript/lib" 23 | }, 24 | "extensions": [ 25 | "dsznajder.es7-react-js-snippets", 26 | "eamodio.gitlens", 27 | "esbenp.prettier-vscode", 28 | "YoavBls.pretty-ts-errors", 29 | "bradlc.vscode-tailwindcss" 30 | ] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # This file will be committed to version control, so make sure not to have any 2 | # secrets in it. If you are cloning this repo, create a copy of this file named 3 | # ".env" and populate it with your secrets. 4 | 5 | # When adding additional environment variables, the schema in "/src/env.js" 6 | # should be updated accordingly. 7 | 8 | DATABASE_URL='postgresql://postgres:root@database:5432/acme' 9 | NEXT_PUBLIC_APP_URL='http://localhost:3000' 10 | MOCK_SEND_EMAIL=true 11 | 12 | SMTP_HOST='smtp.example-host.com' 13 | SMTP_PORT=25 14 | SMTP_USER='smtp_example_username' 15 | SMTP_PASSWORD='smtp_example_password' 16 | 17 | DISCORD_CLIENT_ID='discord_client_id' 18 | DISCORD_CLIENT_SECRET='discord_client_secret' 19 | 20 | # Stripe 21 | # Stripe Secret Key found at https://dashboard.stripe.com/test/apikeys 22 | STRIPE_API_KEY='sk_test_' 23 | # Stripe Webhook Secret found at https://dashboard.stripe.com/test/webhooks/create?endpoint_location=local 24 | # This need to replaced with the webhook secret for your webhook endpoint in production 25 | STRIPE_WEBHOOK_SECRET='whsec_' 26 | # Stripe Product and Price IDs for your created products 27 | # found at https://dashboard.stripe.com/test/products 28 | STRIPE_PRO_MONTHLY_PLAN_ID='price_' 29 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | project: true, 6 | }, 7 | plugins: ["@typescript-eslint"], 8 | extends: [ 9 | "plugin:@next/next/recommended", 10 | "plugin:@typescript-eslint/recommended-type-checked", 11 | "plugin:@typescript-eslint/stylistic-type-checked", 12 | ], 13 | rules: { 14 | // These opinionated rules are enabled in stylistic-type-checked above. 15 | // Feel free to reconfigure them to your own preference. 16 | "@typescript-eslint/array-type": "off", 17 | "@typescript-eslint/consistent-type-definitions": "off", 18 | "@typescript-eslint/no-empty-interface": "off", 19 | 20 | "@typescript-eslint/consistent-type-imports": [ 21 | "warn", 22 | { 23 | prefer: "type-imports", 24 | fixStyle: "inline-type-imports", 25 | }, 26 | ], 27 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 28 | "@typescript-eslint/require-await": "off", 29 | "@typescript-eslint/no-misused-promises": [ 30 | "error", 31 | { 32 | checksVoidReturn: { attributes: false }, 33 | }, 34 | ], 35 | }, 36 | ignorePatterns: ["*.js"], 37 | }; 38 | 39 | module.exports = config; 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | - [ ] Bug fix (non-breaking change which fixes an issue) 9 | - [ ] New feature (non-breaking change which adds functionality) 10 | - [ ] This change requires a documentation update 11 | - [ ] This change requires installing new dependencies 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | concurrency: 8 | group: ci-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | typecheck-and-lint: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: pnpm/action-setup@v2 21 | with: 22 | version: 9 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: 20.x 26 | cache: "pnpm" 27 | 28 | - name: Install dependencies 29 | run: pnpm install --frozen-lockfile 30 | 31 | - name: Type Check and Lint 32 | run: pnpm run typecheck && pnpm run lint 33 | env: 34 | SKIP_ENV_VALIDATION: true 35 | 36 | e2e-test: 37 | needs: typecheck-and-lint 38 | timeout-minutes: 60 39 | runs-on: ubuntu-latest 40 | 41 | env: 42 | DATABASE_URL: ${{secrets.DATABASE_URL}} 43 | DISCORD_CLIENT_ID: ${{secrets.DISCORD_CLIENT_ID}} 44 | DISCORD_CLIENT_SECRET: ${{secrets.DISCORD_CLIENT_SECRET}} 45 | MOCK_SEND_EMAIL: "true" 46 | SMTP_HOST: host 47 | SMTP_PORT: 587 48 | SMTP_USER: user 49 | SMTP_PASSWORD: password 50 | NEXT_PUBLIC_APP_URL: http://localhost:3000 51 | STRIPE_API_KEY: stripe_api_key 52 | STRIPE_WEBHOOK_SECRET: stripe_webhook_secret 53 | STRIPE_PRO_MONTHLY_PLAN_ID: stripe_pro_monthly_plan_id 54 | 55 | steps: 56 | - uses: actions/checkout@v2 57 | - uses: pnpm/action-setup@v2 58 | with: 59 | version: 9 60 | - uses: actions/setup-node@v3 61 | with: 62 | node-version: 20.x 63 | cache: "pnpm" 64 | - name: Install dependencies 65 | run: pnpm install --frozen-lockfile 66 | - name: Build the app 67 | run: pnpm build 68 | - name: Install Playwright Browsers 69 | run: pnpm exec playwright install chromium --with-deps 70 | - name: Run Playwright tests 71 | run: pnpm exec playwright test 72 | - uses: actions/upload-artifact@v4 73 | if: always() 74 | with: 75 | name: playwright-report 76 | path: playwright-report/ 77 | retention-days: 30 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 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 | .pnpm-debug.log* 32 | application.log 33 | 34 | # local env files 35 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 36 | .env 37 | .env*.local 38 | 39 | # vercel 40 | .vercel 41 | 42 | # typescript 43 | *.tsbuildinfo 44 | /test-results/ 45 | /playwright-report/ 46 | /blob-report/ 47 | /playwright/.cache/ 48 | tests/e2e/output -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) [2023] [Touha Zohair] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Auth Starter Template 2 | 3 | ## Motivation 4 | 5 | Implementing authentication in Next.js, especially Email+Password authentication, can be challenging. NextAuth intentionally limits email password functionality to discourage the use of passwords due to security risks and added complexity. However, in certain projects, clients may require user password authentication. Lucia offers a flexible alternative to NextAuth.js, providing more customization options without compromising on security. This template serves as a starting point for building a Next.js app with Lucia authentication. 6 | 7 | ## Lucia vs. NextAuth.js 8 | 9 | Lucia is less opinionated than NextAuth, offering greater flexibility for customization. While Lucia involves more setup, it provides a higher degree of flexibility, making it a suitable choice for projects requiring unique authentication configurations. 10 | 11 | ## Key Features 12 | 13 | - **Authentication:** 💼 Support for Credential and OAuth authentication. 14 | - **Authorization:** 🔒 Easily manage public and protected routes within the `app directory`. 15 | - **Email Verification:** 📧 Verify user identities through email. 16 | - **Password Reset:** 🔑 Streamline password resets by sending email password reset links. 17 | - **Lucia + tRPC:** 🔄 Similar to NextAuth with tRPC, granting access to sessions and user information through tRPC procedures. 18 | - **E2E tests:** 🧪 Catch every issue before your users do with comprehensive E2E testing. 19 | - **Stripe Payment:** 💳 Setup user subscriptions seamlessly with stripe. 20 | - **Email template with react-email:** ✉️ Craft your email templates using React. 21 | - **PostgreSQL Database:** 🛢️ Utilize a PostgreSQL database set up using Drizzle for enhanced performance and type safety. 22 | - **Database Migration:** 🚀 Included migration script to extend the database schema according to your project needs. 23 | 24 | ## Tech Stack 25 | 26 | - [Next.js](https://nextjs.org) 27 | - [Lucia](https://lucia-auth.com/) 28 | - [tRPC](https://trpc.io) 29 | - [Drizzle ORM](https://orm.drizzle.team/) 30 | - [PostgreSQL](https://www.postgresql.org/) 31 | - [Stripe](https://stripe.com/) 32 | - [Tailwind CSS](https://tailwindcss.com) 33 | - [Shadcn UI](https://ui.shadcn.com/) 34 | - [React Hook Form](https://www.react-hook-form.com/) 35 | - [React Email](https://react.email/) 36 | - [Playwright](https://playwright.dev/) 37 | 38 | ## Get Started 39 | 40 | 1. Clone this repository to your local machine. 41 | 2. Copy `.env.example` to `.env` and fill in the required environment variables. 42 | 3. Run `pnpm install` to install dependencies. 43 | 4. `(for node v18 or lower):` Uncomment polyfills for `webCrypto` in `src/lib/auth/index.ts` 44 | 5. Update app title, database prefix, and other parameters in the `src/lib/constants.ts` file. 45 | 6. Run `pnpm db:push` to push your schema to the database. 46 | 7. Execute `pnpm dev` to start the development server and enjoy! 47 | 48 | ## Testing 49 | 50 | 1. Install [Playwright](https://playwright.dev/) (use this command if you want to install chromium only `pnpm exec playwright install chromium --with-deps`) 51 | 2. Build production files using `pnpm build` 52 | 3. Run `pnpm test:e2e` (add --debug flag to open tests in browser in debug mode) 53 | 54 | ## Using Github actions 55 | 56 | Add the following environment variables to your **github actions repository secrets** - 57 | `DATABASE_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` 58 | 59 | ## Roadmap 60 | 61 | - [ ] Update Password 62 | - [x] Stripe Integration 63 | 64 | - [ ] Admin Dashboard (under consideration) 65 | - [ ] Role-Based Access Policy (under consideration) 66 | 67 | ## Contributing 68 | 69 | To contribute, fork the repository and create a feature branch. Test your changes, and if possible, open an issue for discussion before submitting a pull request. Follow project guidelines, and welcome feedback to ensure a smooth integration of your contributions. Your pull requests are warmly welcome. 70 | -------------------------------------------------------------------------------- /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.ts", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | import { DATABASE_PREFIX } from "@/lib/constants"; 3 | 4 | export default defineConfig({ 5 | schema: "./src/server/db/schema.ts", 6 | out: "./drizzle", 7 | dialect: "postgresql", 8 | dbCredentials: { 9 | url: process.env.DATABASE_URL!, 10 | }, 11 | tablesFilter: [`${DATABASE_PREFIX}_*`], 12 | }); 13 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | await import("./src/env.js"); 2 | 3 | /** @type {import("next").NextConfig} */ 4 | const config = {}; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-lucia-auth", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "next build", 8 | "db:push": "dotenv drizzle-kit push", 9 | "db:generate": "dotenv drizzle-kit generate", 10 | "db:migrate": "dotenv drizzle-kit migrate", 11 | "db:studio": "dotenv drizzle-kit studio", 12 | "dev": "next dev", 13 | "start": "next start", 14 | "lint": "next lint", 15 | "typecheck": "tsc --noEmit", 16 | "stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe --latest", 17 | "test:e2e": "playwright test" 18 | }, 19 | "dependencies": { 20 | "@hookform/resolvers": "^3.9.0", 21 | "@lucia-auth/adapter-drizzle": "1.0.7", 22 | "@radix-ui/react-alert-dialog": "^1.1.1", 23 | "@radix-ui/react-dialog": "^1.1.1", 24 | "@radix-ui/react-dropdown-menu": "^2.1.1", 25 | "@radix-ui/react-icons": "^1.3.0", 26 | "@radix-ui/react-label": "^2.1.0", 27 | "@radix-ui/react-slot": "^1.1.0", 28 | "@radix-ui/react-tabs": "^1.1.0", 29 | "@react-email/components": "^0.0.12", 30 | "@react-email/render": "^0.0.10", 31 | "@t3-oss/env-nextjs": "^0.7.3", 32 | "@tanstack/react-query": "^4.36.1", 33 | "@trpc/client": "^10.45.2", 34 | "@trpc/next": "^10.45.2", 35 | "@trpc/react-query": "^10.45.2", 36 | "@trpc/server": "^10.45.2", 37 | "arctic": "^1.9.2", 38 | "class-variance-authority": "^0.7.0", 39 | "clsx": "^2.1.1", 40 | "lucia": "3.2.0", 41 | "next": "^14.2.5", 42 | "next-themes": "^0.2.1", 43 | "nodemailer": "^6.9.14", 44 | "oslo": "^1.2.1", 45 | "postgres": "^3.4.4", 46 | "react": "18.2.0", 47 | "react-dom": "18.2.0", 48 | "react-hook-form": "^7.52.1", 49 | "react-markdown": "^9.0.1", 50 | "react-syntax-highlighter": "^15.5.0", 51 | "rehype-raw": "^7.0.0", 52 | "remark-gfm": "^4.0.0", 53 | "server-only": "^0.0.1", 54 | "sonner": "^1.5.0", 55 | "stripe": "^14.25.0", 56 | "superjson": "^2.2.1", 57 | "tailwind-merge": "^2.4.0", 58 | "tailwindcss-animate": "^1.0.7", 59 | "vaul": "^0.8.9", 60 | "zod": "^3.23.8" 61 | }, 62 | "devDependencies": { 63 | "@next/eslint-plugin-next": "^14.2.5", 64 | "@playwright/test": "^1.45.3", 65 | "@tailwindcss/typography": "^0.5.13", 66 | "@types/eslint": "^8.56.11", 67 | "@types/node": "^18.19.42", 68 | "@types/nodemailer": "^6.4.15", 69 | "@types/react": "^18.3.3", 70 | "@types/react-dom": "^18.3.0", 71 | "@types/react-syntax-highlighter": "^15.5.13", 72 | "@typescript-eslint/eslint-plugin": "^6.21.0", 73 | "@typescript-eslint/parser": "^6.21.0", 74 | "autoprefixer": "^10.4.19", 75 | "dotenv": "^16.4.5", 76 | "dotenv-cli": "^7.4.2", 77 | "drizzle-kit": "^0.23.0", 78 | "drizzle-orm": "^0.32.1", 79 | "eslint": "^8.57.0", 80 | "pg": "^8.12.0", 81 | "postcss": "^8.4.40", 82 | "prettier": "^3.3.3", 83 | "prettier-plugin-tailwindcss": "^0.5.14", 84 | "tailwindcss": "^3.4.7", 85 | "tsx": "^4.16.2", 86 | "typescript": "^5.5.4" 87 | }, 88 | "ct3aMetadata": { 89 | "initVersion": "7.24.2" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | import "dotenv/config"; 3 | 4 | const baseURL = `http://localhost:${process.env.PORT ?? 3000}`; 5 | 6 | export default defineConfig({ 7 | testDir: "./tests/e2e", 8 | outputDir: "./tests/e2e/output", 9 | timeout: 60 * 1000, 10 | fullyParallel: true, 11 | 12 | forbidOnly: !!process.env.CI, 13 | retries: process.env.CI ? 2 : 0, 14 | workers: process.env.CI ? 1 : undefined, 15 | reporter: "html", 16 | use: { 17 | trace: "on-first-retry", 18 | baseURL, 19 | }, 20 | 21 | projects: [ 22 | { 23 | name: "chromium", 24 | use: { ...devices["Desktop Chrome"] }, 25 | }, 26 | ], 27 | webServer: { 28 | command: "npx cross-env NODE_ENV=test npm run start", 29 | url: baseURL, 30 | stdout: "pipe", 31 | stderr: "pipe", 32 | reuseExistingServer: !process.env.CI, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ 2 | const config = { 3 | plugins: ["prettier-plugin-tailwindcss"], 4 | tabWidth: 2, 5 | semi: true, 6 | singleQuote: false, 7 | printWidth: 100, 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | const AuthLayout = ({ children }: { children: ReactNode }) => { 4 | return ( 5 |
{children}
6 | ); 7 | }; 8 | 9 | export default AuthLayout; 10 | -------------------------------------------------------------------------------- /src/app/(auth)/login/discord/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | import { generateId } from "lucia"; 3 | import { OAuth2RequestError } from "arctic"; 4 | import { eq } from "drizzle-orm"; 5 | import { discord, lucia } from "@/lib/auth"; 6 | import { db } from "@/server/db"; 7 | import { Paths } from "@/lib/constants"; 8 | import { users } from "@/server/db/schema"; 9 | 10 | export async function GET(request: Request): Promise { 11 | const url = new URL(request.url); 12 | const code = url.searchParams.get("code"); 13 | const state = url.searchParams.get("state"); 14 | const storedState = cookies().get("discord_oauth_state")?.value ?? null; 15 | 16 | if (!code || !state || !storedState || state !== storedState) { 17 | return new Response(null, { 18 | status: 400, 19 | headers: { Location: Paths.Login }, 20 | }); 21 | } 22 | 23 | try { 24 | const tokens = await discord.validateAuthorizationCode(code); 25 | 26 | const discordUserRes = await fetch("https://discord.com/api/users/@me", { 27 | headers: { 28 | Authorization: `Bearer ${tokens.accessToken}`, 29 | }, 30 | }); 31 | const discordUser = (await discordUserRes.json()) as DiscordUser; 32 | 33 | if (!discordUser.email || !discordUser.verified) { 34 | return new Response( 35 | JSON.stringify({ 36 | error: "Your Discord account must have a verified email address.", 37 | }), 38 | { status: 400, headers: { Location: Paths.Login } }, 39 | ); 40 | } 41 | const existingUser = await db.query.users.findFirst({ 42 | where: (table, { eq, or }) => 43 | or(eq(table.discordId, discordUser.id), eq(table.email, discordUser.email!)), 44 | }); 45 | 46 | const avatar = discordUser.avatar 47 | ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.webp` 48 | : null; 49 | 50 | if (!existingUser) { 51 | const userId = generateId(21); 52 | await db.insert(users).values({ 53 | id: userId, 54 | email: discordUser.email, 55 | emailVerified: true, 56 | discordId: discordUser.id, 57 | avatar, 58 | }); 59 | const session = await lucia.createSession(userId, {}); 60 | const sessionCookie = lucia.createSessionCookie(session.id); 61 | cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); 62 | return new Response(null, { 63 | status: 302, 64 | headers: { Location: Paths.Dashboard }, 65 | }); 66 | } 67 | 68 | if (existingUser.discordId !== discordUser.id || existingUser.avatar !== avatar) { 69 | await db 70 | .update(users) 71 | .set({ 72 | discordId: discordUser.id, 73 | emailVerified: true, 74 | avatar, 75 | }) 76 | .where(eq(users.id, existingUser.id)); 77 | } 78 | const session = await lucia.createSession(existingUser.id, {}); 79 | const sessionCookie = lucia.createSessionCookie(session.id); 80 | cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); 81 | return new Response(null, { 82 | status: 302, 83 | headers: { Location: Paths.Dashboard }, 84 | }); 85 | } catch (e) { 86 | // the specific error message depends on the provider 87 | if (e instanceof OAuth2RequestError) { 88 | // invalid code 89 | return new Response(JSON.stringify({ message: "Invalid code" }), { 90 | status: 400, 91 | }); 92 | } 93 | console.error(e); 94 | 95 | return new Response(JSON.stringify({ message: "internal server error" }), { 96 | status: 500, 97 | }); 98 | } 99 | } 100 | 101 | interface DiscordUser { 102 | id: string; 103 | username: string; 104 | avatar: string | null; 105 | banner: string | null; 106 | global_name: string | null; 107 | banner_color: string | null; 108 | mfa_enabled: boolean; 109 | locale: string; 110 | email: string | null; 111 | verified: boolean; 112 | } 113 | -------------------------------------------------------------------------------- /src/app/(auth)/login/discord/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | import { generateState } from "arctic"; 3 | import { discord } from "@/lib/auth"; 4 | import { env } from "@/env"; 5 | 6 | export async function GET(): Promise { 7 | const state = generateState(); 8 | const url = await discord.createAuthorizationURL(state, { 9 | scopes: ["identify", "email"], 10 | }); 11 | 12 | cookies().set("discord_oauth_state", state, { 13 | path: "/", 14 | secure: env.NODE_ENV === "production", 15 | httpOnly: true, 16 | maxAge: 60 * 10, 17 | sameSite: "lax", 18 | }); 19 | 20 | return Response.redirect(url); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/(auth)/login/login.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { useFormState } from "react-dom"; 5 | import { Input } from "@/components/ui/input"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 8 | import { PasswordInput } from "@/components/password-input"; 9 | import { DiscordLogoIcon } from "@/components/icons"; 10 | import { APP_TITLE } from "@/lib/constants"; 11 | import { login } from "@/lib/auth/actions"; 12 | import { Label } from "@/components/ui/label"; 13 | import { SubmitButton } from "@/components/submit-button"; 14 | 15 | export function Login() { 16 | const [state, formAction] = useFormState(login, null); 17 | 18 | return ( 19 | 20 | 21 | {APP_TITLE} Log In 22 | Log in to your account to access your dashboard 23 | 24 | 25 | 31 |
32 |
33 |
or
34 |
35 |
36 |
37 |
38 | 39 | 47 |
48 | 49 |
50 | 51 | 58 |
59 | 60 |
61 | 64 | 67 |
68 | 69 | {state?.fieldError ? ( 70 |
    71 | {Object.values(state.fieldError).map((err) => ( 72 |
  • 73 | {err} 74 |
  • 75 | ))} 76 |
77 | ) : state?.formError ? ( 78 |

79 | {state?.formError} 80 |

81 | ) : null} 82 | 83 | Log In 84 | 85 | 88 |
89 | 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { validateRequest } from "@/lib/auth/validate-request"; 3 | import { Paths } from "@/lib/constants"; 4 | import { Login } from "./login"; 5 | 6 | export const metadata = { 7 | title: "Login", 8 | description: "Login Page", 9 | }; 10 | 11 | export default async function LoginPage() { 12 | const { user } = await validateRequest(); 13 | 14 | if (user) redirect(Paths.Dashboard); 15 | 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | CardTitle, 7 | } from "@/components/ui/card"; 8 | import { ResetPassword } from "./reset-password"; 9 | 10 | export const metadata = { 11 | title: "Reset Password", 12 | description: "Reset Password Page", 13 | }; 14 | 15 | export default function ResetPasswordPage({ 16 | params, 17 | }: { 18 | params: { token: string }; 19 | }) { 20 | return ( 21 | 22 | 23 | Reset password 24 | Enter new password. 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/[token]/reset-password.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { useFormState } from "react-dom"; 5 | import { toast } from "sonner"; 6 | import { ExclamationTriangleIcon } from "@/components/icons"; 7 | import { SubmitButton } from "@/components/submit-button"; 8 | import { PasswordInput } from "@/components/password-input"; 9 | import { Label } from "@/components/ui/label"; 10 | import { resetPassword } from "@/lib/auth/actions"; 11 | 12 | export function ResetPassword({ token }: { token: string }) { 13 | const [state, formAction] = useFormState(resetPassword, null); 14 | 15 | useEffect(() => { 16 | if (state?.error) { 17 | toast(state.error, { 18 | icon: , 19 | }); 20 | } 21 | }, [state?.error]); 22 | 23 | return ( 24 |
25 | 26 |
27 | 28 | 34 |
35 | Reset Password 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card"; 9 | import { SendResetEmail } from "./send-reset-email"; 10 | import { validateRequest } from "@/lib/auth/validate-request"; 11 | import { Paths } from "@/lib/constants"; 12 | 13 | export const metadata = { 14 | title: "Forgot Password", 15 | description: "Forgot Password Page", 16 | }; 17 | 18 | export default async function ForgotPasswordPage() { 19 | const { user } = await validateRequest(); 20 | 21 | if (user) redirect(Paths.Dashboard); 22 | 23 | return ( 24 | 25 | 26 | Forgot password? 27 | 28 | Password reset link will be sent to your email. 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/send-reset-email.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { useFormState } from "react-dom"; 5 | import Link from "next/link"; 6 | import { useRouter } from "next/navigation"; 7 | import { toast } from "sonner"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Button } from "@/components/ui/button"; 10 | import { Label } from "@/components/ui/label"; 11 | import { SubmitButton } from "@/components/submit-button"; 12 | import { sendPasswordResetLink } from "@/lib/auth/actions"; 13 | import { ExclamationTriangleIcon } from "@/components/icons"; 14 | import { Paths } from "@/lib/constants"; 15 | 16 | export function SendResetEmail() { 17 | const [state, formAction] = useFormState(sendPasswordResetLink, null); 18 | const router = useRouter(); 19 | 20 | useEffect(() => { 21 | if (state?.success) { 22 | toast("A password reset link has been sent to your email."); 23 | router.push(Paths.Login); 24 | } 25 | if (state?.error) { 26 | toast(state.error, { 27 | icon: , 28 | }); 29 | } 30 | }, [state?.error, state?.success]); 31 | 32 | return ( 33 |
34 |
35 | 36 | 43 |
44 | 45 |
46 | 47 | 50 | 51 |
52 | 53 | Reset Password 54 | 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { Signup } from "./signup"; 3 | import { validateRequest } from "@/lib/auth/validate-request"; 4 | import { Paths } from "@/lib/constants"; 5 | 6 | export const metadata = { 7 | title: "Sign Up", 8 | description: "Signup Page", 9 | }; 10 | 11 | export default async function SignupPage() { 12 | const { user } = await validateRequest(); 13 | 14 | if (user) redirect(Paths.Dashboard); 15 | 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(auth)/signup/signup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormState } from "react-dom"; 4 | import Link from "next/link"; 5 | import { PasswordInput } from "@/components/password-input"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 8 | import { Input } from "@/components/ui/input"; 9 | import { DiscordLogoIcon } from "@/components/icons"; 10 | import { APP_TITLE } from "@/lib/constants"; 11 | import { Label } from "@/components/ui/label"; 12 | import { signup } from "@/lib/auth/actions"; 13 | import { SubmitButton } from "@/components/submit-button"; 14 | 15 | export function Signup() { 16 | const [state, formAction] = useFormState(signup, null); 17 | 18 | return ( 19 | 20 | 21 | {APP_TITLE} Sign Up 22 | Sign up to start using the app 23 | 24 | 25 | 31 |
32 |
33 |
or
34 |
35 |
36 | 37 |
38 |
39 | 40 | 48 |
49 |
50 | 51 | 58 |
59 | 60 | {state?.fieldError ? ( 61 |
    62 | {Object.values(state.fieldError).map((err) => ( 63 |
  • 64 | {err} 65 |
  • 66 | ))} 67 |
68 | ) : state?.formError ? ( 69 |

70 | {state?.formError} 71 |

72 | ) : null} 73 |
74 | 75 | 76 | Already signed up? Login instead. 77 | 78 | 79 |
80 | 81 | 82 | Sign Up 83 | 84 | 87 |
88 | 89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/app/(auth)/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | CardTitle, 7 | } from "@/components/ui/card"; 8 | import { redirect } from "next/navigation"; 9 | import { validateRequest } from "@/lib/auth/validate-request"; 10 | import { VerifyCode } from "./verify-code"; 11 | import { Paths } from "@/lib/constants"; 12 | 13 | export const metadata = { 14 | title: "Verify Email", 15 | description: "Verify Email Page", 16 | }; 17 | 18 | export default async function VerifyEmailPage() { 19 | const { user } = await validateRequest(); 20 | 21 | if (!user) redirect(Paths.Login); 22 | if (user.emailVerified) redirect(Paths.Dashboard); 23 | 24 | return ( 25 | 26 | 27 | Verify Email 28 | 29 | Verification code was sent to {user.email}. Check 30 | your spam folder if you can't find the email. 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(auth)/verify-email/verify-code.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Input } from "@/components/ui/input"; 3 | import { Label } from "@radix-ui/react-label"; 4 | import { useEffect, useRef } from "react"; 5 | import { useFormState } from "react-dom"; 6 | import { toast } from "sonner"; 7 | import { ExclamationTriangleIcon } from "@/components/icons"; 8 | import { logout, verifyEmail, resendVerificationEmail as resendEmail } from "@/lib/auth/actions"; 9 | import { SubmitButton } from "@/components/submit-button"; 10 | 11 | export const VerifyCode = () => { 12 | const [verifyEmailState, verifyEmailAction] = useFormState(verifyEmail, null); 13 | const [resendState, resendAction] = useFormState(resendEmail, null); 14 | const codeFormRef = useRef(null); 15 | 16 | useEffect(() => { 17 | if (resendState?.success) { 18 | toast("Email sent!"); 19 | } 20 | if (resendState?.error) { 21 | toast(resendState.error, { 22 | icon: , 23 | }); 24 | } 25 | }, [resendState?.error, resendState?.success]); 26 | 27 | useEffect(() => { 28 | if (verifyEmailState?.error) { 29 | toast(verifyEmailState.error, { 30 | icon: , 31 | }); 32 | } 33 | }, [verifyEmailState?.error]); 34 | 35 | return ( 36 |
37 |
38 | 39 | 40 | 41 | Verify 42 | 43 |
44 |
45 | 46 | Resend Code 47 | 48 |
49 |
50 | 51 | want to use another email? Log out now. 52 | 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/app/(landing)/_components/copy-to-clipboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Input } from "@/components/ui/input"; 5 | import { cn } from "@/lib/utils"; 6 | import { CheckIcon, CopyIcon } from "@radix-ui/react-icons"; 7 | import { useState } from "react"; 8 | import { toast } from "sonner"; 9 | 10 | export const CopyToClipboard = ({ text }: { text: string }) => { 11 | const [copied, setCopied] = useState(false); 12 | const copyToClipboard = async () => { 13 | setCopied(true); 14 | setTimeout(() => { 15 | setCopied(false); 16 | }, 2000); 17 | await navigator.clipboard.writeText(text); 18 | toast("Copied to clipboard", { 19 | icon: , 20 | }); 21 | }; 22 | return ( 23 |
24 | 25 | 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/app/(landing)/_components/footer.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeToggle } from "@/components/theme-toggle"; 2 | import { CodeIcon } from "@radix-ui/react-icons"; 3 | 4 | const githubUrl = "https://github.com/iamtouha/next-lucia-auth"; 5 | const twitterUrl = "https://twitter.com/iamtouha"; 6 | 7 | export const Footer = () => { 8 | return ( 9 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/app/(landing)/_components/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { RocketIcon } from "@/components/icons"; 3 | import { APP_TITLE } from "@/lib/constants"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import { HamburgerMenuIcon } from "@radix-ui/react-icons"; 12 | 13 | const routes = [ 14 | { name: "Home", href: "/" }, 15 | { name: "Features", href: "/#features" }, 16 | { 17 | name: "Documentation", 18 | href: "https://www.touha.dev/posts/simple-nextjs-t3-authentication-with-lucia", 19 | }, 20 | ] as const; 21 | 22 | export const Header = () => { 23 | return ( 24 |
25 |
26 | 27 | 28 | 35 | 36 | 37 |
38 | {routes.map(({ name, href }) => ( 39 | 40 | {name} 41 | 42 | ))} 43 |
44 |
45 |
46 | 50 | {APP_TITLE} 51 | 52 | 63 |
64 | 67 |
68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/app/(landing)/_components/hover-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 3 | import React, { useRef, useState } from "react"; 4 | 5 | type FeaturesProps = { 6 | name: string; 7 | description: string; 8 | logo: React.ReactNode; 9 | }; 10 | 11 | const CardSpotlight = (props: FeaturesProps) => { 12 | const divRef = useRef(null); 13 | const [isFocused, setIsFocused] = useState(false); 14 | const [position, setPosition] = useState({ x: 0, y: 0 }); 15 | const [opacity, setOpacity] = useState(0); 16 | 17 | const handleMouseMove = (e: React.MouseEvent) => { 18 | if (!divRef.current || isFocused) return; 19 | 20 | const div = divRef.current; 21 | const rect = div.getBoundingClientRect(); 22 | 23 | setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top }); 24 | }; 25 | 26 | const handleFocus = () => { 27 | setIsFocused(true); 28 | setOpacity(1); 29 | }; 30 | 31 | const handleBlur = () => { 32 | setIsFocused(false); 33 | setOpacity(0); 34 | }; 35 | 36 | const handleMouseEnter = () => { 37 | setOpacity(1); 38 | }; 39 | 40 | const handleMouseLeave = () => { 41 | setOpacity(0); 42 | }; 43 | 44 | return ( 45 | 54 |
61 |
{props.logo}
62 | 63 | {props.name} 64 | {props.description} 65 | 66 | 67 | ); 68 | }; 69 | 70 | export default CardSpotlight; 71 | -------------------------------------------------------------------------------- /src/app/(landing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { APP_TITLE } from "@/lib/constants"; 2 | import { type Metadata } from "next"; 3 | import { type ReactNode } from "react"; 4 | import { Footer } from "./_components/footer"; 5 | import { Header } from "./_components/header"; 6 | 7 | export const metadata: Metadata = { 8 | title: APP_TITLE, 9 | description: "A Next.js starter with T3 stack and Lucia auth.", 10 | }; 11 | 12 | function LandingPageLayout({ children }: { children: ReactNode }) { 13 | return ( 14 | <> 15 |
16 | {children} 17 |
18 |