├── src
├── server
│ ├── db
│ │ ├── schemas
│ │ │ ├── index.ts
│ │ │ └── auth.ts
│ │ └── index.ts
│ └── Actions
│ │ └── mailAction.ts
├── app
│ ├── page.info.ts
│ ├── (auth)
│ │ ├── sign-in
│ │ │ ├── page.info.ts
│ │ │ └── page.tsx
│ │ ├── sign-up
│ │ │ ├── page.info.ts
│ │ │ └── page.tsx
│ │ ├── email-verified
│ │ │ ├── page.info.ts
│ │ │ └── page.tsx
│ │ ├── forgot-password
│ │ │ ├── page.info.ts
│ │ │ └── page.tsx
│ │ └── reset-password
│ │ │ ├── page.info.ts
│ │ │ └── page.tsx
│ ├── (dashboard)
│ │ ├── admin
│ │ │ ├── loading.tsx
│ │ │ ├── page.info.ts
│ │ │ ├── page.tsx
│ │ │ ├── not-found.tsx
│ │ │ └── error.tsx
│ │ └── user
│ │ │ ├── loading.tsx
│ │ │ ├── page.info.ts
│ │ │ ├── page.tsx
│ │ │ ├── not-found.tsx
│ │ │ └── error.tsx
│ ├── favicon.ico
│ ├── api
│ │ └── auth
│ │ │ └── [...all]
│ │ │ ├── route.info.ts
│ │ │ └── route.ts
│ ├── layout.tsx
│ └── page.tsx
├── styles
│ ├── index.ts
│ └── globals.css
├── lib
│ ├── utils.ts
│ └── auth
│ │ ├── auth-client.ts
│ │ ├── schema.ts
│ │ └── auth.ts
├── components
│ ├── providers
│ │ └── ReactQuery.tsx
│ ├── ui
│ │ ├── sonner.tsx
│ │ ├── label.tsx
│ │ ├── input.tsx
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── form.tsx
│ │ └── dropdown-menu.tsx
│ ├── custom
│ │ ├── LogoutButton.tsx
│ │ ├── ReactPortal.tsx
│ │ └── logo.tsx
│ └── section
│ │ ├── Footer.tsx
│ │ └── Header.tsx
├── routes
│ ├── index.ts
│ ├── hooks.ts
│ ├── utils.ts
│ ├── README.md
│ └── makeRoute.tsx
└── env.ts
├── postcss.config.mjs
├── declarative-routing.config.json
├── public
├── vercel.svg
├── images
│ └── 77627641.jpg
├── window.svg
├── file.svg
├── globe.svg
└── next.svg
├── .github
├── delete-merged-branch-config.yml
├── workflows
│ ├── delete-merged-branches.yml
│ └── labeler.yml
├── auto_assign.yml
└── labeler.yml
├── drizzle.config.ts
├── .vscode
└── settings.json
├── _example_env
├── components.json
├── .gitignore
├── tsconfig.json
├── next.config.ts
├── prettier.config.mjs
├── renovate.json
├── README.md
├── eslint.config.mjs
└── package.json
/src/server/db/schemas/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./auth";
2 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/declarative-routing.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "mode": "nextjs",
3 | "src": "./src/app",
4 | "routes": "./src/routes"
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/page.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "Home",
5 | params: z.object({}),
6 | };
7 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/delete-merged-branch-config.yml:
--------------------------------------------------------------------------------
1 | exclude:
2 | - dev
3 | - main
4 | - dev*
5 | - feature-*
6 | - release
7 | delete_closed_pr: false
8 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/page.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "AuthSignIn",
5 | params: z.object({}),
6 | };
7 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/page.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "AuthSignUp",
5 | params: z.object({}),
6 | };
7 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/admin/loading.tsx:
--------------------------------------------------------------------------------
1 | export default function Loading() {
2 | // Or a custom loading skeleton component
3 | return
Loading...
;
4 | }
5 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/user/loading.tsx:
--------------------------------------------------------------------------------
1 | export default function Loading() {
2 | // Or a custom loading skeleton component
3 | return Loading...
;
4 | }
5 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/user/page.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "DashboardUser",
5 | params: z.object({}),
6 | };
7 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/admin/page.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "DashboardAdmin",
5 | params: z.object({}),
6 | };
7 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Its-Satyajit/nextjs-typescript-tailwind-shadcn-postgresql-drizzle-orm-better-auth-template/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/(auth)/email-verified/page.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "AuthEmailVerified",
5 | params: z.object({}),
6 | };
7 |
--------------------------------------------------------------------------------
/src/app/(auth)/forgot-password/page.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "AuthForgotPassword",
5 | params: z.object({}),
6 | };
7 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/page.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "AuthResetPassword",
5 | params: z.object({}),
6 | };
7 |
--------------------------------------------------------------------------------
/public/images/77627641.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Its-Satyajit/nextjs-typescript-tailwind-shadcn-postgresql-drizzle-orm-better-auth-template/HEAD/public/images/77627641.jpg
--------------------------------------------------------------------------------
/src/app/(dashboard)/admin/page.tsx:
--------------------------------------------------------------------------------
1 | export default function AdminPage() {
2 | return (
3 |
4 |
this is the admin dashboard
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/user/page.tsx:
--------------------------------------------------------------------------------
1 | export default function UserDashBoard() {
2 | return (
3 |
4 |
this is the user dashboard
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/api/auth/[...all]/route.info.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const Route = {
4 | name: "ApiAuthAll",
5 | params: z.object({
6 | all: z.string().array(),
7 | }),
8 | };
9 |
--------------------------------------------------------------------------------
/src/app/api/auth/[...all]/route.ts:
--------------------------------------------------------------------------------
1 | import { toNextJsHandler } from "better-auth/next-js";
2 |
3 | import { auth } from "@/lib/auth/auth";
4 |
5 | export const { POST, GET } = toNextJsHandler(auth);
6 |
--------------------------------------------------------------------------------
/src/styles/index.ts:
--------------------------------------------------------------------------------
1 | import "@fontsource-variable/inter";
2 | import "@fontsource-variable/inter/wght-italic.css";
3 | import "@fontsource-variable/inter/wght.css";
4 |
5 | import "./globals.css";
6 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import env from "@/env";
2 | import type { Config } from "drizzle-kit";
3 |
4 | export default {
5 | schema: "./src/server/db/schemas/",
6 | dialect: "postgresql",
7 | dbCredentials: {
8 | url: env.DATABASE_URL,
9 | },
10 | } satisfies Config;
11 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/admin/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Home } from "@/routes";
2 |
3 | export default function NotFound() {
4 | return (
5 |
6 |
Not Found
7 |
Could not find requested resource
8 |
Return Home
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/user/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Home } from "@/routes";
2 |
3 | export default function NotFound() {
4 | return (
5 |
6 |
Not Found
7 |
Could not find requested resource
8 |
Return Home
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "cSpell.words": [
4 | "elems",
5 | "requireds",
6 | "Satyajit",
7 | "Shadcn",
8 | "sonner",
9 | "trivago"
10 | ],
11 | "prettier.configPath": "./prettier.config.mjs",
12 | "eslint.useFlatConfig": true
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/auth/auth-client.ts:
--------------------------------------------------------------------------------
1 | import env from "@/env";
2 | import { createAuthClient } from "better-auth/react";
3 |
4 | export const {
5 | signIn,
6 | signUp,
7 | useSession,
8 | signOut,
9 | forgetPassword,
10 | resetPassword,
11 | } = createAuthClient({
12 | baseURL: env.NEXT_PUBLIC_BETTER_AUTH_URL,
13 | });
14 |
--------------------------------------------------------------------------------
/.github/workflows/delete-merged-branches.yml:
--------------------------------------------------------------------------------
1 | name: delete branch on close pr
2 | on:
3 | pull_request:
4 | types: [closed]
5 |
6 | jobs:
7 | delete-branch:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: delete branch
11 | uses: SvanBoxel/delete-merged-branch@main
12 | env:
13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
14 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/providers/ReactQuery.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 |
5 | const queryClient = new QueryClient();
6 |
7 | type Props = {
8 | children: React.ReactNode;
9 | };
10 |
11 | export default function ReactQueryProvider({ children }: Props) {
12 | return (
13 | {children}
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/labeler.yml:
--------------------------------------------------------------------------------
1 | name: PR Labeler
2 | permissions:
3 | contents: read
4 | pull-requests: write
5 |
6 | on:
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 |
10 | jobs:
11 | label:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout Code
15 | uses: actions/checkout@v4
16 |
17 | - name: Run Labeler
18 | uses: actions/labeler@v5
19 | with:
20 | repo-token: ${{ secrets.GITHUB_TOKEN }}
21 |
--------------------------------------------------------------------------------
/.github/auto_assign.yml:
--------------------------------------------------------------------------------
1 | # Set to true to add reviewers to pull requests
2 | addReviewers: true
3 |
4 | # Set to true to add assignees to pull requests
5 | addAssignees: true
6 |
7 | # A list of reviewers to be added to pull requests (GitHub user name)
8 | reviewers:
9 | - Its-Satyajit
10 | # A list of keywords to be skipped the process that add reviewers if pull requests include it
11 | skipKeywords:
12 | - wip
13 |
14 | # A number of reviewers added to the pull request
15 | # Set 0 to add all the reviewers (default: 0)
16 | numberOfReviewers: 0
17 |
--------------------------------------------------------------------------------
/_example_env:
--------------------------------------------------------------------------------
1 | #Server
2 | NODE_ENV=development
3 | DATABASE_URL=postgresql://postgres:password@localhost:5432/default
4 | BETTER_AUTH_SECRET=secret
5 | BETTER_AUTH_URL=http://localhost:3000
6 | REACT_EDITOR=atom
7 | SKIP_BUILD_CHECKS=false
8 |
9 |
10 | MAIL_HOST=gmail
11 | MAIL_USERNAME=email@gmail.com
12 | MAIL_PASSWORD=password
13 | MAIL_FROM=email@gmail.com
14 | EMAIL_VERIFICATION_CALLBACK_URL=http://localhost:3000
15 |
16 | GITHUB_CLIENT_ID=secret
17 | GITHUB_CLIENT_SECRET=secret
18 |
19 | #Client
20 | NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
--------------------------------------------------------------------------------
/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/styles/globals.css",
9 | "baseColor": "slate",
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 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/src/server/db/index.ts:
--------------------------------------------------------------------------------
1 | import env from "@/env";
2 | import { drizzle } from "drizzle-orm/postgres-js";
3 | import postgres from "postgres";
4 |
5 | import * as schema from "./schemas";
6 |
7 | /**
8 | * Cache the database connection in development. This avoids creating a new connection on every HMR
9 | * update.
10 | */
11 | const globalForDb = globalThis as unknown as {
12 | conn: postgres.Sql | undefined;
13 | };
14 |
15 | const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
16 | if (env.NODE_ENV !== "production") globalForDb.conn = conn;
17 |
18 | export const db = drizzle(conn, { schema });
19 |
--------------------------------------------------------------------------------
/.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.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
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 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/admin/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 |
5 | export default function Error({
6 | error,
7 | reset,
8 | }: {
9 | error: Error & { digest?: string };
10 | reset: () => void;
11 | }) {
12 | useEffect(() => {
13 | // Log the error to an error reporting service
14 | console.error(error);
15 | }, [error]);
16 |
17 | return (
18 |
19 |
Something went wrong!
20 | reset()
24 | }
25 | >
26 | Try again
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/user/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 |
5 | export default function Error({
6 | error,
7 | reset,
8 | }: {
9 | error: Error & { digest?: string };
10 | reset: () => void;
11 | }) {
12 | useEffect(() => {
13 | // Log the error to an error reporting service
14 | console.error(error);
15 | }, [error]);
16 |
17 | return (
18 |
19 |
Something went wrong!
20 | reset()
24 | }
25 | >
26 | Try again
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 |
5 | import { Toaster as Sonner, type ToasterProps } from "sonner";
6 |
7 | const Toaster = ({ ...props }: ToasterProps) => {
8 | const { theme = "system" } = useTheme();
9 |
10 | return (
11 |
23 | );
24 | };
25 |
26 | export { Toaster };
27 |
--------------------------------------------------------------------------------
/src/app/(auth)/email-verified/page.tsx:
--------------------------------------------------------------------------------
1 | import { Home } from "@/routes";
2 |
3 | import { buttonVariants } from "@/components/ui/button";
4 |
5 | export default async function EmailVerifiedPage() {
6 | return (
7 |
8 |
9 | Email Verified!
10 |
11 |
12 | Your email has been successfully verified.
13 |
14 |
19 | Go to home
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import * as LabelPrimitive from "@radix-ui/react-label";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | function Label({
10 | className,
11 | ...props
12 | }: React.ComponentProps) {
13 | return (
14 |
22 | );
23 | }
24 |
25 | export { Label };
26 |
--------------------------------------------------------------------------------
/src/components/custom/LogoutButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 |
5 | import { Home } from "@/routes";
6 |
7 | import { signOut } from "@/lib/auth/auth-client";
8 |
9 | import { Button } from "../ui/button";
10 |
11 | interface Props {
12 | className?: string;
13 | }
14 | export default function LogoutButton({ className }: Props) {
15 | const router = useRouter();
16 | return (
17 |
20 | signOut({
21 | fetchOptions: {
22 | onSuccess: () => router.push(Home()),
23 | },
24 | })
25 | }
26 | variant={"outline"}
27 | >
28 | Sign Out
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "erasableSyntaxOnly": true,
11 | "module": "esnext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"],
24 | "$/*": ["./"]
25 | }
26 | },
27 | "include": [
28 | "next-env.d.ts",
29 | "**/*.ts",
30 | "**/*.tsx",
31 | "**/*.js",
32 | "**/*.mjs",
33 | ".next/types/**/*.ts"
34 | ],
35 | "exclude": ["node_modules"]
36 | }
37 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | import env from "@/env";
4 |
5 | const nextConfig: NextConfig = {
6 | experimental: {
7 | reactCompiler: env.NODE_ENV === "production",
8 |
9 | turbo: {
10 | resolveExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
11 | },
12 | },
13 | eslint: {
14 | ignoreDuringBuilds: env.SKIP_BUILD_CHECKS === "true",
15 | },
16 | typescript: {
17 | ignoreBuildErrors: env.SKIP_BUILD_CHECKS === "true",
18 | },
19 | images: {
20 | remotePatterns: [
21 | {
22 | protocol: "https",
23 | hostname: "images.unsplash.com",
24 | },
25 | {
26 | hostname: "avatars.githubusercontent.com",
27 | },
28 | {
29 | protocol: "http",
30 | hostname: "localhost",
31 | },
32 | ],
33 | },
34 | };
35 |
36 | export default nextConfig;
37 |
--------------------------------------------------------------------------------
/prettier.config.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-anonymous-default-export */
2 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
3 | export default {
4 | plugins: [
5 | "@trivago/prettier-plugin-sort-imports",
6 | "prettier-plugin-organize-attributes",
7 | "prettier-plugin-tailwindcss",
8 | ],
9 |
10 | importOrder: [
11 | "^server-only$",
12 | "^client-only$",
13 | "^@/components/shared/providers/ReactScan$",
14 | "^react-scan$",
15 | "^(react|next)",
16 | "",
17 | "^@/app/(.*)$",
18 | "^@/features/(.*)$",
19 | "^@/components/(.*)$",
20 | "^@/data/(.*)$",
21 | "^@/lib/(.*)$",
22 | "^@/hooks/(.*)$",
23 | "^@/types/(.*)$",
24 | "^@/styles/(.*)$",
25 | "^[./]",
26 | ],
27 | importOrderSeparation: true,
28 | importOrderSortSpecifiers: true,
29 | importOrderGroupNamespaceSpecifiers: true,
30 | };
31 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | );
19 | }
20 |
21 | export { Input };
22 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 |
3 | import env from "@/env";
4 | import "@/styles";
5 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
6 |
7 | import ReactQueryProvider from "@/components/providers/ReactQuery";
8 | import Footer from "@/components/section/Footer";
9 | import Header from "@/components/section/Header";
10 | import { Toaster } from "@/components/ui/sonner";
11 |
12 | export const metadata: Metadata = {
13 | title: "Create Next App",
14 | description: "Generated by create next app",
15 | };
16 |
17 | export default function RootLayout({
18 | children,
19 | }: Readonly<{
20 | children: React.ReactNode;
21 | }>) {
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
{children}
29 |
30 |
31 | {env.NODE_ENV === "development" ? : null}
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:recommended"
4 | ],
5 | "baseBranches": [
6 | "dev"
7 | ],
8 | "packageRules": [
9 | {
10 | "matchDepTypes": ["dependencies", "devDependencies"],
11 | "matchUpdateTypes": ["major"],
12 | "automerge": false,
13 | "enabled": true
14 | },
15 |
16 | {
17 | "matchDepTypes": ["devDependencies"],
18 | "automerge": true,
19 | "matchUpdateTypes": [
20 | "minor",
21 | "patch"
22 | ]
23 | },
24 |
25 | {
26 | "matchDepTypes": ["dependencies"],
27 | "automerge": true,
28 | "matchUpdateTypes": ["patch"],
29 | "stabilityDays": 3
30 | },
31 |
32 | {
33 | "matchDepTypes": ["dependencies"],
34 | "matchUpdateTypes": ["minor"],
35 | "automerge": false
36 | },
37 | {
38 | "matchDepTypes": ["dependencies", "devDependencies"],
39 | "matchUpdateTypes": ["patch", "minor"],
40 | "groupName": "minor-patch-updates",
41 | "groupSlug": "minor-patch-updates",
42 | "automerge": true
43 | }
44 | ],
45 | "dependencyDashboard": true,
46 | "schedule": ["every weekend"],
47 | "timezone": "Asia/Kolkata",
48 | "branchPrefix": "renovate/",
49 | "labels": [
50 | "dependencies",
51 | "auto-update"
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | function Avatar({
10 | className,
11 | ...props
12 | }: React.ComponentProps) {
13 | return (
14 |
22 | );
23 | }
24 |
25 | function AvatarImage({
26 | className,
27 | ...props
28 | }: React.ComponentProps) {
29 | return (
30 |
35 | );
36 | }
37 |
38 | function AvatarFallback({
39 | className,
40 | ...props
41 | }: React.ComponentProps) {
42 | return (
43 |
51 | );
52 | }
53 |
54 | export { Avatar, AvatarImage, AvatarFallback };
55 |
--------------------------------------------------------------------------------
/src/components/custom/ReactPortal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { createPortal } from "react-dom";
5 |
6 |
7 |
8 | function createWrapperAndAppendToBody(wrapperId: string): HTMLElement {
9 | const wrapperElement = document.createElement("div");
10 | wrapperElement.setAttribute("id", wrapperId);
11 | document.body.appendChild(wrapperElement);
12 | return wrapperElement;
13 | }
14 |
15 | interface ReactPortalProps {
16 | children: React.ReactNode;
17 | wrapperId?: string;
18 | }
19 |
20 | const ReactPortal: React.FC = ({
21 | children,
22 | wrapperId = crypto.randomUUID(),
23 | }) => {
24 | const [wrapperElement, setWrapperElement] = useState(
25 | null,
26 | );
27 |
28 | useEffect(() => {
29 | let isMounted = true;
30 | let element = document.getElementById(wrapperId);
31 | if (!element) {
32 | element = createWrapperAndAppendToBody(wrapperId);
33 | }
34 | if (isMounted) {
35 | setWrapperElement(element);
36 | }
37 | return () => {
38 | isMounted = false;
39 | if (element?.parentNode) {
40 | element.parentNode.removeChild(element);
41 | }
42 | };
43 | }, [wrapperId]);
44 |
45 | if (!wrapperElement) return null;
46 | return createPortal(children, wrapperElement);
47 | };
48 |
49 | export default ReactPortal;
50 |
--------------------------------------------------------------------------------
/src/server/Actions/mailAction.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import "server-only";
4 |
5 | import env from "@/env";
6 | import nodemailer from "nodemailer";
7 | import winston from "winston";
8 |
9 | const logger = winston.createLogger({
10 | level: "debug",
11 | format: winston.format.json(),
12 | transports: [new winston.transports.Console()],
13 | });
14 |
15 | if (env.NODE_ENV !== "production") {
16 | logger.add(
17 | new winston.transports.Console({
18 | format: winston.format.simple(),
19 | }),
20 | );
21 | }
22 |
23 | export async function sendMail({
24 | to,
25 | subject,
26 | html,
27 | }: {
28 | to: string;
29 | subject: string;
30 | html: string;
31 | }): Promise {
32 | const transporter = nodemailer.createTransport({
33 | service: process.env.MAIL_HOST,
34 | auth: { user: env.MAIL_USERNAME, pass: env.MAIL_PASSWORD },
35 | });
36 |
37 | const mailOptions = {
38 | from: env.MAIL_FROM,
39 | to,
40 | subject,
41 | html,
42 | };
43 |
44 | logger.info(`Sending mail to - ${to}`);
45 |
46 | try {
47 | const info = await transporter.sendMail(mailOptions);
48 | logger.info(`Email sent: ${info.response}`);
49 | } catch (error: unknown) {
50 | if (error instanceof Error) {
51 | logger.error(`Error sending email: ${error.message}`);
52 | } else {
53 | logger.error("An unknown error occurred while sending the email.");
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/lib/auth/schema.ts:
--------------------------------------------------------------------------------
1 | import { object, string } from "zod";
2 |
3 | const getPasswordSchema = (type: "password" | "confirmPassword") =>
4 | string({ required_error: `${type} is required` })
5 | .min(8, `${type} must be atleast 8 characters`)
6 | .max(32, `${type} can not exceed 32 characters`);
7 |
8 | const getEmailSchema = () =>
9 | string({ required_error: "Email is required" })
10 | .min(1, "Email is required")
11 | .email("Invalid email");
12 |
13 | const getNameSchema = () =>
14 | string({ required_error: "Name is required" })
15 | .min(1, "Name is required")
16 | .max(50, "Name must be less than 50 characters");
17 |
18 | export const signUpSchema = object({
19 | name: getNameSchema(),
20 | email: getEmailSchema(),
21 | password: getPasswordSchema("password"),
22 | confirmPassword: getPasswordSchema("confirmPassword"),
23 | }).refine((data) => data.password === data.confirmPassword, {
24 | message: "Passwords don't match",
25 | path: ["confirmPassword"],
26 | });
27 |
28 | export const signInSchema = object({
29 | email: getEmailSchema(),
30 | password: getPasswordSchema("password"),
31 | });
32 |
33 | export const forgotPasswordSchema = object({
34 | email: getEmailSchema(),
35 | });
36 |
37 | export const resetPasswordSchema = object({
38 | password: getPasswordSchema("password"),
39 | confirmPassword: getPasswordSchema("confirmPassword"),
40 | }).refine((data) => data.password === data.confirmPassword, {
41 | message: "Passwords don't match",
42 | path: ["confirmPassword"],
43 | });
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/src/components/section/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export default function Footer() {
4 | return (
5 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/.github/labeler.yml:
--------------------------------------------------------------------------------
1 | # Add 'backend' label to changes in backend-related files
2 | backend:
3 | - changed-files:
4 | - any-glob-to-any-file:
5 | - "src/lib/**"
6 | - "src/routes/**"
7 | - "src/types/**"
8 | - "src/env.ts"
9 |
10 | # Add 'frontend' label to changes in frontend-related files
11 | frontend:
12 | - changed-files:
13 | - any-glob-to-any-file:
14 | - "src/app/**"
15 | - "src/components/**"
16 | - "src/features/**"
17 | - "src/data/**"
18 |
19 | # Add 'utilities' label to changes in utility functions
20 | utilities:
21 | - changed-files:
22 | - any-glob-to-any-file:
23 | - "src/lib/utilities/**"
24 |
25 | # Add 'mock-data' label to changes in mock data
26 | mock-data:
27 | - changed-files:
28 | - any-glob-to-any-file:
29 | - "src/data/mock/**"
30 |
31 | # Add 'tests' label to changes in test files
32 | tests:
33 | - changed-files:
34 | - any-glob-to-any-file:
35 | - "tests/**"
36 |
37 | # Add 'documentation' label to changes in documentation files
38 | documentation:
39 | - changed-files:
40 | - any-glob-to-any-file:
41 | - "docs/**"
42 | - "**/*.md"
43 |
44 | # Add 'ci/cd' label to changes in CI/CD or GitHub workflow files
45 | ci/cd:
46 | - changed-files:
47 | - any-glob-to-any-file:
48 | - ".github/workflows/**"
49 |
50 | # Add 'dependencies' label to changes in dependency files
51 | dependencies:
52 | - changed-files:
53 | - any-glob-to-any-file:
54 | - "package.json"
55 | - "package-lock.json"
56 | - "pnpm-lock.yaml"
57 | - "yarn.lock"
58 |
59 | # Add 'environment' label to changes in environment configuration
60 | environment:
61 | - changed-files:
62 | - any-glob-to-any-file:
63 | - "src/env.ts"
64 |
65 | # Add 'configuration' label to changes in configuration files
66 | configuration:
67 | - changed-files:
68 | - any-glob-to-any-file:
69 | - ".eslintrc.js"
70 | - ".prettierrc"
71 | - "tsconfig.json"
72 |
--------------------------------------------------------------------------------
/src/server/db/schemas/auth.ts:
--------------------------------------------------------------------------------
1 | import { boolean, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
2 |
3 | export const userRoleEnums = pgEnum("Role", ["user", "admin", "superAdmin"]);
4 |
5 | export const user = pgTable("user", {
6 | id: text("id").primaryKey(),
7 | name: text("name").notNull(),
8 | email: text("email").notNull().unique(),
9 | emailVerified: boolean("email_verified").notNull(),
10 | image: text("image"),
11 | createdAt: timestamp("created_at").notNull(),
12 | updatedAt: timestamp("updated_at").notNull(),
13 | role: userRoleEnums("role").default("user").notNull(),
14 | banned: boolean("banned"),
15 | banReason: text("ban_reason"),
16 | banExpires: timestamp("ban_expires"),
17 | });
18 |
19 | export const session = pgTable("session", {
20 | id: text("id").primaryKey(),
21 | expiresAt: timestamp("expires_at").notNull(),
22 | token: text("token").notNull().unique(),
23 | createdAt: timestamp("created_at").notNull(),
24 | updatedAt: timestamp("updated_at").notNull(),
25 | ipAddress: text("ip_address"),
26 | userAgent: text("user_agent"),
27 | userId: text("user_id")
28 | .notNull()
29 | .references(() => user.id),
30 | impersonatedBy: text("impersonated_by"),
31 | });
32 |
33 | export const account = pgTable("account", {
34 | id: text("id").primaryKey(),
35 | accountId: text("account_id").notNull(),
36 | providerId: text("provider_id").notNull(),
37 | userId: text("user_id")
38 | .notNull()
39 | .references(() => user.id),
40 | accessToken: text("access_token"),
41 | refreshToken: text("refresh_token"),
42 | idToken: text("id_token"),
43 | accessTokenExpiresAt: timestamp("access_token_expires_at"),
44 | refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
45 | scope: text("scope"),
46 | password: text("password"),
47 | createdAt: timestamp("created_at").notNull(),
48 | updatedAt: timestamp("updated_at").notNull(),
49 | });
50 |
51 | export const verification = pgTable("verification", {
52 | id: text("id").primaryKey(),
53 | identifier: text("identifier").notNull(),
54 | value: text("value").notNull(),
55 | expiresAt: timestamp("expires_at").notNull(),
56 | createdAt: timestamp("created_at"),
57 | updatedAt: timestamp("updated_at"),
58 | });
59 |
--------------------------------------------------------------------------------
/src/routes/index.ts:
--------------------------------------------------------------------------------
1 | // Automatically generated by declarative-routing, do NOT edit
2 | import { z } from "zod";
3 | import { makeRoute } from "./makeRoute";
4 |
5 | const defaultInfo = {
6 | search: z.object({})
7 | };
8 |
9 | import * as HomeRoute from "@/app/page.info";
10 | import * as AuthEmailVerifiedRoute from "@/app/(auth)/email-verified/page.info";
11 | import * as AuthForgotPasswordRoute from "@/app/(auth)/forgot-password/page.info";
12 | import * as AuthResetPasswordRoute from "@/app/(auth)/reset-password/page.info";
13 | import * as AuthSignInRoute from "@/app/(auth)/sign-in/page.info";
14 | import * as AuthSignUpRoute from "@/app/(auth)/sign-up/page.info";
15 | import * as DashboardAdminRoute from "@/app/(dashboard)/admin/page.info";
16 | import * as DashboardUserRoute from "@/app/(dashboard)/user/page.info";
17 | import * as ApiAuthAllRoute from "@/app/api/auth/[...all]/route.info";
18 |
19 | export const Home = makeRoute(
20 | "/",
21 | {
22 | ...defaultInfo,
23 | ...HomeRoute.Route
24 | }
25 | );
26 | export const AuthEmailVerified = makeRoute(
27 | "/(auth)/email-verified",
28 | {
29 | ...defaultInfo,
30 | ...AuthEmailVerifiedRoute.Route
31 | }
32 | );
33 | export const AuthForgotPassword = makeRoute(
34 | "/(auth)/forgot-password",
35 | {
36 | ...defaultInfo,
37 | ...AuthForgotPasswordRoute.Route
38 | }
39 | );
40 | export const AuthResetPassword = makeRoute(
41 | "/(auth)/reset-password",
42 | {
43 | ...defaultInfo,
44 | ...AuthResetPasswordRoute.Route
45 | }
46 | );
47 | export const AuthSignIn = makeRoute(
48 | "/(auth)/sign-in",
49 | {
50 | ...defaultInfo,
51 | ...AuthSignInRoute.Route
52 | }
53 | );
54 | export const AuthSignUp = makeRoute(
55 | "/(auth)/sign-up",
56 | {
57 | ...defaultInfo,
58 | ...AuthSignUpRoute.Route
59 | }
60 | );
61 | export const DashboardAdmin = makeRoute(
62 | "/(dashboard)/admin",
63 | {
64 | ...defaultInfo,
65 | ...DashboardAdminRoute.Route
66 | }
67 | );
68 | export const DashboardUser = makeRoute(
69 | "/(dashboard)/user",
70 | {
71 | ...defaultInfo,
72 | ...DashboardUserRoute.Route
73 | }
74 | );
75 | export const ApiAuthAll = makeRoute(
76 | "/api/auth/[...all]",
77 | {
78 | ...defaultInfo,
79 | ...ApiAuthAllRoute.Route
80 | }
81 | );
82 |
83 |
--------------------------------------------------------------------------------
/src/routes/hooks.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-return */
2 | /* eslint-disable @typescript-eslint/unbound-method */
3 | import {
4 | useParams as useNextParams,
5 | useSearchParams as useNextSearchParams,
6 | useRouter,
7 | } from "next/navigation";
8 |
9 | import { z } from "zod";
10 |
11 | import type { RouteBuilder } from "./makeRoute";
12 |
13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
14 | const emptySchema = z.object({});
15 |
16 | type PushOptions = Parameters["push"]>[1];
17 |
18 | export function usePush<
19 | Params extends z.ZodSchema,
20 | Search extends z.ZodSchema = typeof emptySchema,
21 | >(builder: RouteBuilder) {
22 | const { push } = useRouter();
23 | return (
24 | p: z.input,
25 | search?: z.input,
26 | options?: PushOptions,
27 | ) => {
28 | push(builder(p, search), options);
29 | };
30 | }
31 |
32 | export function useParams<
33 | Params extends z.ZodSchema,
34 | Search extends z.ZodSchema = typeof emptySchema,
35 | >(builder: RouteBuilder): z.output {
36 | const res = builder.paramsSchema.safeParse(useNextParams());
37 | if (!res.success) {
38 | throw new Error(
39 | `Invalid route params for route ${builder.routeName}: ${res.error.message}`,
40 | );
41 | }
42 | return res.data;
43 | }
44 |
45 | export function useSearchParams<
46 | Params extends z.ZodSchema,
47 | Search extends z.ZodSchema = typeof emptySchema,
48 | >(builder: RouteBuilder): z.output {
49 | const res = builder.searchSchema.safeParse(
50 | convertURLSearchParamsToObject(useNextSearchParams()),
51 | );
52 | if (!res.success) {
53 | throw new Error(
54 | `Invalid search params for route ${builder.routeName}: ${res.error.message}`,
55 | );
56 | }
57 | return res.data;
58 | }
59 |
60 | function convertURLSearchParamsToObject(
61 | params: Readonly | null,
62 | ): Record {
63 | if (!params) {
64 | return {};
65 | }
66 |
67 | const obj: Record = {};
68 |
69 | for (const [key, value] of params.entries()) {
70 | if (params.getAll(key).length > 1) {
71 | obj[key] = params.getAll(key);
72 | } else {
73 | obj[key] = value;
74 | }
75 | }
76 | return obj;
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { Slot } from "@radix-ui/react-slot";
4 | import { type VariantProps, cva } from "class-variance-authority";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const buttonVariants = cva(
9 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
10 | {
11 | variants: {
12 | variant: {
13 | default:
14 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
15 | destructive:
16 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
17 | outline:
18 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
19 | secondary:
20 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
21 | ghost:
22 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
23 | link: "text-primary underline-offset-4 hover:underline",
24 | },
25 | size: {
26 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
27 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
28 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
29 | icon: "size-9",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | },
37 | );
38 |
39 | function Button({
40 | className,
41 | variant,
42 | size,
43 | asChild = false,
44 | ...props
45 | }: React.ComponentProps<"button"> &
46 | VariantProps & {
47 | asChild?: boolean;
48 | }) {
49 | const Comp = asChild ? Slot : "button";
50 |
51 | return (
52 |
57 | );
58 | }
59 |
60 | export { Button, buttonVariants };
61 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | function Card({ className, ...props }: React.ComponentProps<"div">) {
6 | return (
7 |
15 | );
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19 | return (
20 |
28 | );
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32 | return (
33 |
38 | );
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42 | return (
43 |
48 | );
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52 | return (
53 |
61 | );
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65 | return (
66 |
71 | );
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75 | return (
76 |
81 | );
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | };
93 |
--------------------------------------------------------------------------------
/src/env.ts:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs";
2 | import { z } from "zod";
3 |
4 | const env = createEnv({
5 | /**
6 | * Specify your server-side environment variables schema here. This way you can ensure the app
7 | * isn't built with invalid env vars.
8 | */
9 | server: {
10 | NODE_ENV: z.enum(["development", "test", "production"]),
11 | DATABASE_URL: z.string(),
12 | BETTER_AUTH_SECRET: z.string(),
13 | BETTER_AUTH_URL: z.string(),
14 | SKIP_BUILD_CHECKS: z.string(),
15 | MAIL_HOST: z.string(),
16 | MAIL_USERNAME: z.string(),
17 | MAIL_PASSWORD: z.string(),
18 | MAIL_FROM: z.string().email(),
19 |
20 | GITHUB_CLIENT_ID: z.string(),
21 | GITHUB_CLIENT_SECRET: z.string(),
22 | EMAIL_VERIFICATION_CALLBACK_URL: z.string(),
23 | },
24 |
25 | /**
26 | * Specify your client-side environment variables schema here. This way you can ensure the app
27 | * isn't built with invalid env vars. To expose them to the client, prefix them with
28 | * `NEXT_PUBLIC_`.
29 | */
30 | client: {
31 | NEXT_PUBLIC_BETTER_AUTH_URL: z.string(),
32 | },
33 |
34 | /**
35 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
36 | * middlewares) or client-side so we need to destruct manually.
37 | */
38 | runtimeEnv: {
39 | SKIP_BUILD_CHECKS: process.env.SKIP_BUILD_CHECKS,
40 | BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
41 | DATABASE_URL: process.env.DATABASE_URL,
42 | NODE_ENV: process.env.NODE_ENV,
43 | BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
44 | NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
45 |
46 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
47 | GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
48 | EMAIL_VERIFICATION_CALLBACK_URL:
49 | process.env.EMAIL_VERIFICATION_CALLBACK_URL,
50 |
51 | MAIL_HOST: process.env.MAIL_HOST,
52 | MAIL_USERNAME: process.env.MAIL_USERNAME,
53 | MAIL_PASSWORD: process.env.MAIL_PASSWORD,
54 | MAIL_FROM: process.env.MAIL_FROM,
55 | },
56 | /**
57 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
58 | * useful for Docker builds.
59 | */
60 | skipValidation: !!process.env.SKIP_ENV_VALIDATION,
61 | /**
62 | * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
63 | * `SOME_VAR=''` will throw an error.
64 | */
65 | emptyStringAsUndefined: true,
66 | });
67 |
68 | export default env;
69 |
--------------------------------------------------------------------------------
/src/lib/auth/auth.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 |
3 | import env from "@/env";
4 | import { sendMail } from "@/server/Actions/mailAction";
5 | import { db } from "@/server/db";
6 | import { betterAuth } from "better-auth";
7 | import { drizzleAdapter } from "better-auth/adapters/drizzle";
8 | import { nextCookies } from "better-auth/next-js";
9 | import { admin, openAPI } from "better-auth/plugins";
10 |
11 | export type Session = typeof auth.$Infer.Session;
12 |
13 | export const auth = betterAuth({
14 | database: drizzleAdapter(db, {
15 | provider: "pg",
16 | }),
17 |
18 | plugins: [openAPI(), admin(), nextCookies()], //nextCookies() should be last plugin in the list
19 | session: {
20 | expiresIn: 60 * 60 * 24 * 7, // 7 days
21 | updateAge: 60 * 60 * 24, // 1 day (every 1 day the session expiration is updated)
22 | cookieCache: {
23 | enabled: true,
24 | maxAge: 5 * 60, // Cache duration in seconds
25 | },
26 | },
27 | user: {
28 | additionalFields: {
29 | role: {
30 | type: "string",
31 | default: "user",
32 | required: false,
33 | defaultValue: "user",
34 | },
35 | },
36 | changeEmail: {
37 | enabled: true,
38 | sendChangeEmailVerification: async ({ newEmail, url }) => {
39 | await sendMail({
40 | to: newEmail,
41 | subject: "Verify your email change",
42 | html: `Click the link to verify: ${url}
`,
43 | });
44 | },
45 | },
46 | },
47 | socialProviders: {
48 | github: {
49 | clientId: env.GITHUB_CLIENT_ID,
50 | clientSecret: env.GITHUB_CLIENT_SECRET,
51 | },
52 | },
53 |
54 | emailAndPassword: {
55 | enabled: true,
56 | requireEmailVerification: true,
57 | sendResetPassword: async ({ user, url }) => {
58 | await sendMail({
59 | to: user.email,
60 | subject: "Reset your password",
61 | html: `Click the link to reset your password: ${url}
`,
62 | });
63 | },
64 | },
65 | emailVerification: {
66 | sendOnSignUp: true,
67 | autoSignInAfterVerification: true,
68 | sendVerificationEmail: async ({ user, token }) => {
69 | const verificationUrl = `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${token}&callbackURL=${env.EMAIL_VERIFICATION_CALLBACK_URL}`;
70 | await sendMail({
71 | to: user.email,
72 | subject: "Verify your email address",
73 | html: `Click the link to verify your email: ${verificationUrl}
`,
74 | });
75 | },
76 | },
77 | });
78 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { FlatCompat } from "@eslint/eslintrc";
2 | import js from "@eslint/js";
3 | import pluginQuery from "@tanstack/eslint-plugin-query";
4 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
5 | import tsParser from "@typescript-eslint/parser";
6 | import drizzlePlugin from "eslint-plugin-drizzle";
7 | import path from "node:path";
8 | import { fileURLToPath } from "node:url";
9 |
10 | const __filename = fileURLToPath(import.meta.url);
11 | const __dirname = path.dirname(__filename);
12 |
13 | const compat = new FlatCompat({
14 | baseDirectory: __dirname,
15 | recommendedConfig: js.configs.recommended,
16 | allConfig: js.configs.all,
17 | });
18 |
19 | const eslintConfig = [
20 | ...pluginQuery.configs["flat/recommended"],
21 | ...compat.extends(
22 | "next/core-web-vitals",
23 | "plugin:@typescript-eslint/recommended-type-checked",
24 | "plugin:@typescript-eslint/stylistic-type-checked",
25 | ),
26 | {
27 | plugins: {
28 | "@typescript-eslint": typescriptEslint,
29 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
30 | drizzle: drizzlePlugin,
31 | },
32 | languageOptions: {
33 | parser: tsParser,
34 | ecmaVersion: 5,
35 | sourceType: "script",
36 | parserOptions: {
37 | project: true,
38 | },
39 | },
40 | rules: {
41 | "@typescript-eslint/no-unsafe-assignment": "warn",
42 | "@typescript-eslint/no-unsafe-call": "warn",
43 | "@typescript-eslint/array-type": "off",
44 | "@typescript-eslint/consistent-type-definitions": "off",
45 | "@typescript-eslint/consistent-type-imports": [
46 | "warn",
47 | {
48 | prefer: "type-imports",
49 | fixStyle: "inline-type-imports",
50 | },
51 | ],
52 | "@typescript-eslint/no-unused-vars": [
53 | "warn",
54 | {
55 | argsIgnorePattern: "^_",
56 | },
57 | ],
58 | "@typescript-eslint/require-await": "off",
59 | "@typescript-eslint/no-misused-promises": [
60 | "error",
61 | {
62 | checksVoidReturn: {
63 | attributes: false,
64 | },
65 | },
66 | ],
67 | "drizzle/enforce-delete-with-where": [
68 | "error",
69 | {
70 | drizzleObjectName: ["db", "ctx.db"],
71 | },
72 | ],
73 | "drizzle/enforce-update-with-where": [
74 | "error",
75 | {
76 | drizzleObjectName: ["db", "ctx.db"],
77 | },
78 | ],
79 | },
80 | },
81 | ];
82 |
83 | export default eslintConfig;
84 |
--------------------------------------------------------------------------------
/src/components/section/Header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 |
5 | import { AuthSignIn, AuthSignUp, DashboardAdmin, Home } from "@/routes";
6 |
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuItem,
11 | DropdownMenuLabel,
12 | DropdownMenuSeparator,
13 | DropdownMenuTrigger,
14 | } from "@/components/ui/dropdown-menu";
15 |
16 | import { signOut, useSession } from "@/lib/auth/auth-client";
17 |
18 | import Logo from "../custom/logo";
19 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
20 | import { Button } from "../ui/button";
21 |
22 | export default function Header() {
23 | const router = useRouter();
24 | const { data: session } = useSession();
25 | return (
26 |
27 |
28 |
29 |
30 |
31 |
32 | {session ? (
33 | <>
34 |
35 |
36 |
37 |
41 | Its-Satyajit
42 |
43 |
44 |
45 | {session.user.name}
46 |
47 |
48 | DashBoard
49 |
50 |
53 | signOut({
54 | fetchOptions: {
55 | onSuccess: () => router.push(Home()),
56 | },
57 | })
58 | }
59 | >
60 | Sign Out
61 |
62 |
63 |
64 | >
65 | ) : (
66 | <>
67 |
68 | Sign In
69 |
70 |
71 | Sign Up
72 |
73 | >
74 | )}
75 |
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/src/app/(auth)/forgot-password/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useForm } from "react-hook-form";
5 |
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { toast } from "sonner";
8 | import type { z } from "zod";
9 |
10 | import { Button } from "@/components/ui/button";
11 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12 | import {
13 | Form,
14 | FormControl,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage,
19 | } from "@/components/ui/form";
20 | import { Input } from "@/components/ui/input";
21 |
22 | import { forgetPassword } from "@/lib/auth/auth-client";
23 | import { forgotPasswordSchema } from "@/lib/auth/schema";
24 |
25 | export default function ForgotPassword() {
26 | const [isPending, setIsPending] = useState(false);
27 |
28 | const form = useForm>({
29 | resolver: zodResolver(forgotPasswordSchema),
30 | defaultValues: {
31 | email: "",
32 | },
33 | });
34 |
35 | const onSubmit = async (data: z.infer) => {
36 | setIsPending(true);
37 |
38 | const { error } = await forgetPassword({
39 | email: data.email,
40 | redirectTo: "/reset-password",
41 | });
42 |
43 | if (error) {
44 | toast.error(error.message);
45 | } else {
46 | toast(
47 | "If an account exists with this email, you will receive a password reset link.",
48 | );
49 | }
50 | setIsPending(false);
51 | };
52 |
53 | return (
54 |
55 |
56 |
57 |
58 | Forgot Password
59 |
60 |
61 |
62 |
84 |
85 |
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "new",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "docs:generate": "typedoc",
8 | "build": "pnpm dr:build && pnpm next build",
9 | "check": "next lint && tsc --noEmit",
10 | "db:generate": "drizzle-kit generate",
11 | "db:migrate": "drizzle-kit migrate",
12 | "db:push": "drizzle-kit push",
13 | "db:studio": "drizzle-kit studio",
14 | "dev:turbo": "next dev --turbo",
15 | "lint": "next lint",
16 | "lint:fix": "next lint --fix",
17 | "preview": "next build && next start",
18 | "start": "next start",
19 | "typecheck": "tsc --noEmit",
20 | "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx,cjs,mjs}\" --cache",
21 | "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx,cjs,mjs}\" --cache",
22 | "dr:build": "npx declarative-routing build",
23 | "dr:build:watch": "npx declarative-routing build --watch",
24 | "format:write:watch": "pnpm onchange -j 12 -d 1500 -f change -f add \"**/*\" --exclude-path .gitignore -- prettier -w \"**/*.{ts,tsx,js,jsx,mdx,cjs,mjs,json}\" --cache --log-level warn",
25 | "dev": "concurrently -c \"#604CC3, #8FD14F ,#FF6600,#FFA600\" \"pnpm dev:turbo\" \"pnpm dr:build:watch\" \"pnpm format:write:watch\" --names \"Next.js,Routing,Prettier\""
26 | },
27 | "dependencies": {
28 | "@better-fetch/fetch": "^1.1.17",
29 | "@fontsource-variable/inter": "^5.2.5",
30 | "@hookform/resolvers": "^4.1.3",
31 | "@icons-pack/react-simple-icons": "^12.3.0",
32 | "@radix-ui/react-avatar": "^1.1.3",
33 | "@radix-ui/react-dropdown-menu": "^2.1.6",
34 | "@radix-ui/react-label": "^2.1.2",
35 | "@radix-ui/react-slot": "^1.1.2",
36 | "@t3-oss/env-nextjs": "^0.12.0",
37 | "@tailwindcss/typography": "^0.5.16",
38 | "@tanstack/react-query": "^5.69.0",
39 | "@tanstack/react-query-devtools": "^5.69.0",
40 | "babel-plugin-react-compiler": "beta",
41 | "better-auth": "^1.2.4",
42 | "class-variance-authority": "^0.7.1",
43 | "clsx": "^2.1.1",
44 | "concurrently": "^9.1.2",
45 | "declarative-routing": "^0.1.20",
46 | "dotenv": "^16.4.7",
47 | "drizzle-orm": "^0.41.0",
48 | "eslint-plugin-drizzle": "^0.2.3",
49 | "lucide-react": "^0.483.0",
50 | "next": "15.2.4",
51 | "next-themes": "^0.4.6",
52 | "nodemailer": "^6.10.0",
53 | "onchange": "^7.1.0",
54 | "postgres": "^3.4.5",
55 | "query-string": "^9.1.1",
56 | "react": "^19.0.0",
57 | "react-dom": "^19.0.0",
58 | "react-hook-form": "^7.54.2",
59 | "server-only": "^0.0.1",
60 | "sonner": "^2.0.1",
61 | "tailwind-merge": "^3.0.2",
62 | "tailwindcss-animate": "^1.0.7",
63 | "winston": "^3.17.0",
64 | "zod": "^3.24.2"
65 | },
66 | "devDependencies": {
67 | "@eslint/eslintrc": "^3.3.1",
68 | "@eslint/js": "^9.23.0",
69 | "@next/eslint-plugin-next": "^15.2.4",
70 | "@tailwindcss/postcss": "^4.0.15",
71 | "@tanstack/eslint-plugin-query": "^5.68.0",
72 | "@trivago/prettier-plugin-sort-imports": "^5.2.2",
73 | "@types/node": "^22.13.13",
74 | "@types/nodemailer": "^6.4.17",
75 | "@types/react": "^19.0.12",
76 | "@types/react-dom": "^19.0.4",
77 | "@typescript-eslint/eslint-plugin": "^8.28.0",
78 | "@typescript-eslint/parser": "^8.28.0",
79 | "drizzle-kit": "^0.30.5",
80 | "eslint": "^9.23.0",
81 | "eslint-config-next": "15.2.4",
82 | "eslint-plugin-react-hooks": "^5.2.0",
83 | "prettier": "^3.5.3",
84 | "prettier-plugin-organize-attributes": "^1.0.0",
85 | "prettier-plugin-tailwindcss": "^0.6.11",
86 | "tailwindcss": "^4.0.15",
87 | "tsx": "^4.19.3",
88 | "typescript": "^5.8.2"
89 | },
90 | "pnpm": {
91 | "onlyBuiltDependencies": [
92 | "esbuild",
93 | "sharp"
94 | ]
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { useState } from "react";
5 | import { useForm } from "react-hook-form";
6 |
7 | import { AuthSignIn, Home } from "@/routes";
8 | import { zodResolver } from "@hookform/resolvers/zod";
9 | import { toast } from "sonner";
10 | import type { z } from "zod";
11 |
12 | import { Button } from "@/components/ui/button";
13 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
14 | import {
15 | Form,
16 | FormControl,
17 | FormField,
18 | FormItem,
19 | FormLabel,
20 | FormMessage,
21 | } from "@/components/ui/form";
22 | import { Input } from "@/components/ui/input";
23 |
24 | import { signUp } from "@/lib/auth/auth-client";
25 | import { signUpSchema } from "@/lib/auth/schema";
26 |
27 | export default function SignUp() {
28 | const router = useRouter();
29 | const [pending, setPending] = useState(false);
30 |
31 | const form = useForm>({
32 | resolver: zodResolver(signUpSchema),
33 | defaultValues: {
34 | name: "",
35 | email: "",
36 | password: "",
37 | confirmPassword: "",
38 | },
39 | });
40 |
41 | const onSubmit = async (values: z.infer) => {
42 | await signUp.email(
43 | {
44 | email: values.email,
45 | password: values.password,
46 | name: values.name,
47 | },
48 | {
49 | onRequest: () => {
50 | setPending(true);
51 | },
52 | onSuccess: () => {
53 | toast.success("Successfully signed up!", {
54 | description:
55 | "You have successfully signed up! Please check your email for verification.",
56 | });
57 |
58 | router.push(Home());
59 | router.refresh();
60 | },
61 | onError: (ctx) => {
62 | toast.error("Something went wrong!", {
63 | description: ctx.error.message ?? "Something went wrong.",
64 | });
65 | },
66 | },
67 | );
68 | setPending(false);
69 | };
70 |
71 | return (
72 |
73 |
74 |
75 |
76 | Create Account
77 |
78 |
79 |
80 |
116 |
117 |
118 |
119 | Already have an account? Sign in
120 |
121 |
122 |
123 |
124 |
125 | );
126 | }
127 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @plugin '@tailwindcss/typography';
4 | @plugin 'tailwindcss-animate';
5 |
6 | @custom-variant dark (&:is(.dark *));
7 |
8 | @theme {
9 |
10 |
11 | --color-background: hsl(var(--background));
12 | --color-foreground: hsl(var(--foreground));
13 |
14 | --color-card: hsl(var(--card));
15 | --color-card-foreground: hsl(var(--card-foreground));
16 |
17 | --color-popover: hsl(var(--popover));
18 | --color-popover-foreground: hsl(var(--popover-foreground));
19 |
20 | --color-primary: hsl(var(--primary));
21 | --color-primary-foreground: hsl(var(--primary-foreground));
22 |
23 | --color-secondary: hsl(var(--secondary));
24 | --color-secondary-foreground: hsl(var(--secondary-foreground));
25 |
26 | --color-muted: hsl(var(--muted));
27 | --color-muted-foreground: hsl(var(--muted-foreground));
28 |
29 | --color-accent: hsl(var(--accent));
30 | --color-accent-foreground: hsl(var(--accent-foreground));
31 |
32 | --color-destructive: hsl(var(--destructive));
33 | --color-destructive-foreground: hsl(var(--destructive-foreground));
34 |
35 | --color-border: hsl(var(--border));
36 | --color-input: hsl(var(--input));
37 | --color-ring: hsl(var(--ring));
38 |
39 | --color-chart-1: hsl(var(--chart-1));
40 | --color-chart-2: hsl(var(--chart-2));
41 | --color-chart-3: hsl(var(--chart-3));
42 | --color-chart-4: hsl(var(--chart-4));
43 | --color-chart-5: hsl(var(--chart-5));
44 |
45 | --radius-lg: var(--radius);
46 | --radius-md: calc(var(--radius) - 2px);
47 | --radius-sm: calc(var(--radius) - 4px);
48 | }
49 |
50 | /*
51 | The default border color has changed to `currentColor` in Tailwind CSS v4,
52 | so we've added these compatibility styles to make sure everything still
53 | looks the same as it did with Tailwind CSS v3.
54 |
55 | If we ever want to remove these styles, we need to add an explicit border
56 | color utility to any element that depends on these defaults.
57 | */
58 | @layer base {
59 | *,
60 | ::after,
61 | ::before,
62 | ::backdrop,
63 | ::file-selector-button {
64 | border-color: var(--color-gray-200, currentColor);
65 | }
66 | }
67 |
68 | @layer utilities {
69 | body {
70 | font-family: "Inter Variable";
71 | }
72 | }
73 |
74 | @layer base {
75 | :root {
76 | --background: 0 0% 100%;
77 | --foreground: 222.2 84% 4.9%;
78 | --card: 0 0% 100%;
79 | --card-foreground: 222.2 84% 4.9%;
80 | --popover: 0 0% 100%;
81 | --popover-foreground: 222.2 84% 4.9%;
82 | --primary: 222.2 47.4% 11.2%;
83 | --primary-foreground: 210 40% 98%;
84 | --secondary: 210 40% 96.1%;
85 | --secondary-foreground: 222.2 47.4% 11.2%;
86 | --muted: 210 40% 96.1%;
87 | --muted-foreground: 215.4 16.3% 46.9%;
88 | --accent: 210 40% 96.1%;
89 | --accent-foreground: 222.2 47.4% 11.2%;
90 | --destructive: 0 84.2% 60.2%;
91 | --destructive-foreground: 210 40% 98%;
92 | --border: 214.3 31.8% 91.4%;
93 | --input: 214.3 31.8% 91.4%;
94 | --ring: 222.2 84% 4.9%;
95 | --chart-1: 12 76% 61%;
96 | --chart-2: 173 58% 39%;
97 | --chart-3: 197 37% 24%;
98 | --chart-4: 43 74% 66%;
99 | --chart-5: 27 87% 67%;
100 | --radius: 0.5rem;
101 | }
102 |
103 | .dark {
104 | --background: 222.2 84% 4.9%;
105 | --foreground: 210 40% 98%;
106 | --card: 222.2 84% 4.9%;
107 | --card-foreground: 210 40% 98%;
108 | --popover: 222.2 84% 4.9%;
109 | --popover-foreground: 210 40% 98%;
110 | --primary: 210 40% 98%;
111 | --primary-foreground: 222.2 47.4% 11.2%;
112 | --secondary: 217.2 32.6% 17.5%;
113 | --secondary-foreground: 210 40% 98%;
114 | --muted: 217.2 32.6% 17.5%;
115 | --muted-foreground: 215 20.2% 65.1%;
116 | --accent: 217.2 32.6% 17.5%;
117 | --accent-foreground: 210 40% 98%;
118 | --destructive: 0 62.8% 30.6%;
119 | --destructive-foreground: 210 40% 98%;
120 | --border: 217.2 32.6% 17.5%;
121 | --input: 217.2 32.6% 17.5%;
122 | --ring: 212.7 26.8% 83.9%;
123 | --chart-1: 220 70% 50%;
124 | --chart-2: 160 60% 45%;
125 | --chart-3: 30 80% 55%;
126 | --chart-4: 280 65% 60%;
127 | --chart-5: 340 75% 55%;
128 | }
129 | }
130 |
131 | @layer base {
132 | * {
133 | @apply border-border;
134 | }
135 |
136 | body {
137 | @apply bg-background text-foreground;
138 |
139 | }
140 |
141 | h1,
142 | h2,
143 | h3,
144 | h4,
145 | h5,
146 | h6 {
147 | @apply text-balance;
148 | }
149 |
150 | p {
151 | @apply text-pretty;
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import {
5 | Controller,
6 | type ControllerProps,
7 | type FieldPath,
8 | type FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | useFormState,
12 | } from "react-hook-form";
13 |
14 | import type * as LabelPrimitive from "@radix-ui/react-label";
15 | import { Slot } from "@radix-ui/react-slot";
16 |
17 | import { Label } from "@/components/ui/label";
18 |
19 | import { cn } from "@/lib/utils";
20 |
21 | const Form = FormProvider;
22 |
23 | type FormFieldContextValue<
24 | TFieldValues extends FieldValues = FieldValues,
25 | TName extends FieldPath = FieldPath,
26 | > = {
27 | name: TName;
28 | };
29 |
30 | const FormFieldContext = React.createContext(
31 | {} as FormFieldContextValue,
32 | );
33 |
34 | const FormField = <
35 | TFieldValues extends FieldValues = FieldValues,
36 | TName extends FieldPath = FieldPath,
37 | >({
38 | ...props
39 | }: ControllerProps) => {
40 | return (
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | const useFormField = () => {
48 | const fieldContext = React.useContext(FormFieldContext);
49 | const itemContext = React.useContext(FormItemContext);
50 | const { getFieldState } = useFormContext();
51 | const formState = useFormState({ name: fieldContext.name });
52 | const fieldState = getFieldState(fieldContext.name, formState);
53 |
54 | if (!fieldContext) {
55 | throw new Error("useFormField should be used within ");
56 | }
57 |
58 | const { id } = itemContext;
59 |
60 | return {
61 | id,
62 | name: fieldContext.name,
63 | formItemId: `${id}-form-item`,
64 | formDescriptionId: `${id}-form-item-description`,
65 | formMessageId: `${id}-form-item-message`,
66 | ...fieldState,
67 | };
68 | };
69 |
70 | type FormItemContextValue = {
71 | id: string;
72 | };
73 |
74 | const FormItemContext = React.createContext(
75 | {} as FormItemContextValue,
76 | );
77 |
78 | function FormItem({ className, ...props }: React.ComponentProps<"div">) {
79 | const id = React.useId();
80 |
81 | return (
82 |
83 |
88 |
89 | );
90 | }
91 |
92 | function FormLabel({
93 | className,
94 | ...props
95 | }: React.ComponentProps) {
96 | const { error, formItemId } = useFormField();
97 |
98 | return (
99 |
106 | );
107 | }
108 |
109 | function FormControl({ ...props }: React.ComponentProps) {
110 | const { error, formItemId, formDescriptionId, formMessageId } =
111 | useFormField();
112 |
113 | return (
114 |
125 | );
126 | }
127 |
128 | function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
129 | const { formDescriptionId } = useFormField();
130 |
131 | return (
132 |
138 | );
139 | }
140 |
141 | function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
142 | const { error, formMessageId } = useFormField();
143 | const body = error ? String(error?.message ?? "") : props.children;
144 |
145 | if (!body) {
146 | return null;
147 | }
148 |
149 | return (
150 |
156 | {body}
157 |
158 | );
159 | }
160 |
161 | export {
162 | useFormField,
163 | Form,
164 | FormItem,
165 | FormLabel,
166 | FormControl,
167 | FormDescription,
168 | FormMessage,
169 | FormField,
170 | };
171 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter, useSearchParams } from "next/navigation";
4 | import { Suspense, useState } from "react";
5 | import { useForm } from "react-hook-form";
6 |
7 | import { AuthSignIn } from "@/routes";
8 | import { zodResolver } from "@hookform/resolvers/zod";
9 | import { toast } from "sonner";
10 | import type { z } from "zod";
11 |
12 | import { Button } from "@/components/ui/button";
13 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
14 | import {
15 | Form,
16 | FormControl,
17 | FormField,
18 | FormItem,
19 | FormLabel,
20 | FormMessage,
21 | } from "@/components/ui/form";
22 | import { Input } from "@/components/ui/input";
23 |
24 | import { resetPassword } from "@/lib/auth/auth-client";
25 | import { resetPasswordSchema } from "@/lib/auth/schema";
26 |
27 | function ResetPasswordContent() {
28 | const router = useRouter();
29 |
30 | const searchParams = useSearchParams();
31 | const error = searchParams.get("error");
32 | const [isPending, setIsPending] = useState(false);
33 |
34 | const form = useForm>({
35 | resolver: zodResolver(resetPasswordSchema),
36 | defaultValues: {
37 | password: "",
38 | confirmPassword: "",
39 | },
40 | });
41 |
42 | const onSubmit = async (data: z.infer) => {
43 | setIsPending(true);
44 | const { error } = await resetPassword({
45 | newPassword: data.password,
46 | });
47 | if (error) {
48 | toast(error.message);
49 | } else {
50 | toast("Password reset successful. Login to continue.");
51 | router.push(AuthSignIn());
52 | }
53 | setIsPending(false);
54 | };
55 |
56 | if (error === "invalid_token") {
57 | return (
58 |
59 |
60 |
61 |
62 | Invalid Reset Link
63 |
64 |
65 |
66 |
67 |
68 | This password reset link is invalid or has expired.
69 |
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
77 | return (
78 |
79 |
80 |
81 |
82 | Reset Password
83 |
84 |
85 |
86 |
126 |
127 |
128 |
129 |
130 | );
131 | }
132 |
133 | export default function ResetPassword() {
134 | return (
135 |
136 |
137 |
138 | );
139 | }
140 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { useForm } from "react-hook-form";
5 |
6 | import { AuthForgotPassword, Home } from "@/routes";
7 | import type { ErrorContext } from "@better-fetch/fetch";
8 | import { zodResolver } from "@hookform/resolvers/zod";
9 | import { SiGithub } from "@icons-pack/react-simple-icons";
10 | import { toast } from "sonner";
11 | import type { z } from "zod";
12 |
13 | import { Button } from "@/components/ui/button";
14 | // import LoadingButton from "@/components/loading-button";
15 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
16 | import {
17 | Form,
18 | FormControl,
19 | FormField,
20 | FormItem,
21 | FormLabel,
22 | FormMessage,
23 | } from "@/components/ui/form";
24 | import { Input } from "@/components/ui/input";
25 |
26 | import { signIn } from "@/lib/auth/auth-client";
27 | import { signInSchema } from "@/lib/auth/schema";
28 |
29 | export default function SignIn() {
30 | const router = useRouter();
31 |
32 | // const [pendingCredentials, setPendingCredentials] = useState(false);
33 | // const [pendingGithub, setPendingGithub] = useState(false);
34 |
35 | const form = useForm>({
36 | resolver: zodResolver(signInSchema),
37 | defaultValues: {
38 | email: "",
39 | password: "",
40 | },
41 | });
42 |
43 | const handleCredentialsSignIn = async (
44 | values: z.infer,
45 | ) => {
46 | await signIn.email(
47 | {
48 | email: values.email,
49 | password: values.password,
50 | },
51 | {
52 | onRequest: () => {
53 | // setPendingCredentials(true);
54 | },
55 | onSuccess: async () => {
56 | router.push(Home());
57 | router.refresh();
58 | toast.success("Successfully signed in!", {
59 | description: "You have successfully signed in!",
60 | });
61 | },
62 | onError: (ctx: ErrorContext) => {
63 | toast.error("Something went wrong!", {
64 | description: ctx.error.message ?? "Something went wrong.",
65 | });
66 | },
67 | },
68 | );
69 | // setPendingCredentials(false);
70 | };
71 |
72 | const handleSignInWithGithub = async () => {
73 | await signIn.social(
74 | {
75 | provider: "github",
76 | },
77 | {
78 | onRequest: () => {
79 | // setPendingGithub(true);
80 | },
81 | onSuccess: async () => {
82 | router.push(Home());
83 | router.refresh();
84 | },
85 | onError: (ctx: ErrorContext) => {
86 | toast.error("Something went wrong!", {
87 | description: ctx.error.message ?? "Something went wrong.",
88 | });
89 | },
90 | },
91 | );
92 | // setPendingGithub(false);
93 | };
94 |
95 | return (
96 |
97 |
98 |
99 |
100 | Sign In
101 |
102 |
103 |
104 |
136 |
137 |
138 |
143 |
144 | Continue with GitHub
145 |
146 |
147 |
148 |
149 | Forgot password?
150 |
151 |
152 |
153 |
154 |
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import React from "react";
3 |
4 | import { SiGithub } from "@icons-pack/react-simple-icons";
5 |
6 | import { Button } from "@/components/ui/button";
7 |
8 | const TechBadge = ({ label }: { label: string }) => (
9 |
10 | {label}
11 |
12 | );
13 |
14 | export default function Home() {
15 | const technologies = {
16 | core: ["Next.js 15", "TypeScript 5", "React 19", "Tailwind CSS 3"],
17 | ecosystem: ["Drizzle ORM", "React Query 5", "PostgreSQL", "Radix UI"],
18 | tools: ["pnpm", "ESLint 9", "Prettier 3", "Winston"],
19 | ui: ["Shadcn/Ui", "Simple Icons", "Lucide Icons"],
20 | };
21 |
22 | return (
23 |
24 | {/* Main content */}
25 |
26 |
27 | {/* Hero */}
28 |
29 |
30 |
31 | Supercharge
32 | {" "}
33 |
34 | your next Next.js project.
35 |
36 |
37 |
38 | Unlock the potential of a cutting-edge Next.js template, equipped
39 | with seamless authentication, robust database integration, and
40 | advanced tooling. Empowering developers of all levels to build
41 | scalable web applications with speed and precision.
42 |
43 |
44 | {/* Primary actions */}
45 |
46 |
47 |
48 | Get started
49 |
50 |
51 |
52 |
56 |
57 | View source
58 |
59 |
60 |
61 |
62 |
63 | {/* Technology sections */}
64 |
65 | {Object.entries(technologies).map(([category, techs]) => (
66 |
67 |
68 | {category.charAt(0).toUpperCase() + category.slice(1)}
69 |
70 |
71 | {techs.map((tech) => (
72 |
73 | ))}
74 |
75 |
76 | ))}
77 |
78 |
79 | {/* Features */}
80 |
81 |
82 | Key Features
83 |
84 |
85 | {[
86 | {
87 | title: "Authentication",
88 | description:
89 | "Complete auth flow with email verification and role-based access",
90 | },
91 | {
92 | title: "Database Integration",
93 | description:
94 | "Type-safe operations with Drizzle ORM and PostgreSQL",
95 | },
96 | {
97 | title: "Development Tools",
98 | description:
99 | "ESLint, Prettier, and TypeScript for robust development",
100 | },
101 | {
102 | title: "UI Components",
103 | description:
104 | "Accessible components with Radix UI & Shadcn/UI",
105 | },
106 | {
107 | title: "API Integration",
108 | description: "Built-in API routes and middleware support",
109 | },
110 | {
111 | title: "Performance",
112 | description: "Optimized builds and automatic code splitting",
113 | },
114 | ].map((feature) => (
115 |
116 |
{feature.title}
117 |
118 | {feature.description}
119 |
120 |
121 | ))}
122 |
123 |
124 |
125 |
126 |
127 | );
128 | }
129 |
--------------------------------------------------------------------------------
/src/components/custom/logo.tsx:
--------------------------------------------------------------------------------
1 | export default function Logo({ className }: { className?: string }) {
2 | return (
3 | // biome-ignore lint/a11y/noSvgWithoutTitle:
4 |
14 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/routes/utils.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-return */
2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
4 | /* eslint-disable @typescript-eslint/no-explicit-any */
5 | import { z } from "zod";
6 |
7 | type ParsedData = { error?: string; data?: T };
8 |
9 | export function safeParseSearchParams(
10 | schema: T,
11 | searchParams: URLSearchParams,
12 | ): z.infer {
13 | const paramsArray = getAllParamsAsArrays(searchParams);
14 | return processSchema(schema, paramsArray);
15 | }
16 |
17 | function processSchema(
18 | schema: z.ZodTypeAny,
19 | paramsArray: Record,
20 | ): Record {
21 | if (schema instanceof z.ZodOptional) {
22 | schema = schema._def.innerType;
23 | }
24 | switch (schema.constructor) {
25 | case z.ZodObject: {
26 | const shape = (schema as z.ZodObject).shape;
27 | return parseShape(shape, paramsArray);
28 | }
29 |
30 | case z.ZodUnion: {
31 | const options = (
32 | schema as z.ZodUnion<
33 | [z.ZodObject, ...z.ZodObject[]]
34 | >
35 | )._def.options;
36 | for (const option of options) {
37 | const shape = option.shape;
38 | const requireds = getRequireds(shape);
39 |
40 | const result = parseShape(shape, paramsArray, true);
41 | const keys = Object.keys(result);
42 |
43 | if (requireds.every((key) => keys.includes(key))) {
44 | return result;
45 | }
46 | }
47 | return {};
48 | }
49 | default:
50 | throw new Error("Unsupported schema type");
51 | }
52 | }
53 |
54 | function getRequireds(shape: z.ZodRawShape) {
55 | const keys: string[] = [];
56 | for (const key in shape) {
57 | const fieldShape = shape[key];
58 | if (
59 | !(fieldShape instanceof z.ZodDefault) &&
60 | !(fieldShape instanceof z.ZodOptional)
61 | )
62 | keys.push(key);
63 | }
64 | return keys;
65 | }
66 |
67 | function parseShape(
68 | shape: z.ZodRawShape,
69 | paramsArray: Record,
70 | isPartOfUnion = false,
71 | ): Record {
72 | const parsed: Record = {};
73 |
74 | for (const key in shape) {
75 | if (shape.hasOwnProperty(key)) {
76 | const fieldSchema: z.ZodTypeAny = shape[key];
77 | if (paramsArray[key]) {
78 | const fieldData = convertToRequiredType(paramsArray[key], fieldSchema);
79 |
80 | if (fieldData.error) {
81 | if (isPartOfUnion) return {};
82 | continue;
83 | }
84 | const result = fieldSchema.safeParse(fieldData.data);
85 | if (result.success) parsed[key] = result.data;
86 | } else if (fieldSchema instanceof z.ZodDefault) {
87 | const result = fieldSchema.safeParse(undefined);
88 | if (result.success) parsed[key] = result.data;
89 | }
90 | }
91 | }
92 |
93 | return parsed;
94 | }
95 |
96 | function getAllParamsAsArrays(
97 | searchParams: URLSearchParams,
98 | ): Record {
99 | const params: Record = {};
100 |
101 | searchParams.forEach((value, key) => {
102 | if (!params[key]) {
103 | params[key] = [];
104 | }
105 | params[key].push(value);
106 | });
107 |
108 | return params;
109 | }
110 |
111 | function convertToRequiredType(
112 | values: string[],
113 | schema: z.ZodTypeAny,
114 | ): ParsedData {
115 | const usedSchema = getInnerType(schema);
116 | if (values.length > 1 && !(usedSchema instanceof z.ZodArray))
117 | return { error: "Multiple values for non-array field" };
118 | const value = parseValues(usedSchema, values);
119 | if (value.error && schema.constructor === z.ZodDefault) {
120 | return { data: undefined };
121 | }
122 | return value;
123 | }
124 |
125 | function parseValues(schema: any, values: string[]): ParsedData {
126 | switch (schema.constructor) {
127 | case z.ZodNumber:
128 | return parseNumber(values[0]);
129 | case z.ZodBoolean:
130 | return parseBoolean(values[0]);
131 | case z.ZodString:
132 | return { data: values[0] };
133 | case z.ZodArray: {
134 | const elementSchema = schema._def.type;
135 | switch (elementSchema.constructor) {
136 | case z.ZodNumber:
137 | return parseArray(values, parseNumber);
138 | case z.ZodBoolean:
139 | return parseArray(values, parseBoolean);
140 | case z.ZodString:
141 | return { data: values };
142 | default:
143 | return {
144 | error:
145 | "unsupported array element type " +
146 | String(elementSchema.constructor),
147 | };
148 | }
149 | }
150 | default:
151 | return { error: "unsupported type " + String(schema.constructor) };
152 | }
153 | }
154 |
155 | function getInnerType(schema: z.ZodTypeAny) {
156 | switch (schema.constructor) {
157 | case z.ZodOptional:
158 | case z.ZodDefault:
159 | return schema._def.innerType;
160 | default:
161 | return schema;
162 | }
163 | }
164 |
165 | function parseNumber(str: string): ParsedData {
166 | const num = +str;
167 | return isNaN(num) ? { error: `${str} is NaN` } : { data: num };
168 | }
169 |
170 | function parseBoolean(str: string): ParsedData {
171 | switch (str) {
172 | case "true":
173 | return { data: true };
174 | case "false":
175 | return { data: false };
176 | default:
177 | return { error: `${str} is not a boolean` };
178 | }
179 | }
180 |
181 | function parseArray(
182 | values: string[],
183 | parseFunction: (str: string) => ParsedData,
184 | ): ParsedData {
185 | const numbers = values.map(parseFunction);
186 | const error = numbers.find((n) => n.error)?.error;
187 | if (error) return { error };
188 | return { data: numbers.map((n) => n.data!) };
189 | }
190 |
--------------------------------------------------------------------------------
/src/routes/README.md:
--------------------------------------------------------------------------------
1 | This application supports typesafe routing for NextJS using the `declarative-routing` system.
2 |
3 | # What is `declarative-routing`?
4 |
5 | Declarative Routes is a system for typesafe routing in React. It uses a combination of TypeScript and a custom routing system to ensure that your routes are always in sync with your code. You'll never have to worry about broken links or missing routes again.
6 |
7 | In NextJS applications, Declarative Routes also handles API routes, so you'll have typesafe input and output from all of your APIs. In addition to `fetch` functions that are written for you automatically.
8 |
9 | # Route List
10 |
11 | Here are the routes of the application:
12 |
13 | | Route | Verb | Route Name | Using It |
14 | | ----- | ---- | ---------- | ------------- |
15 | | `/` | - | `Home` | `` |
16 |
17 | To use the routes, you can import them from `@/routes` and use them in your code.
18 |
19 | # Using the routes in your application
20 |
21 | For pages, use the `Link` component (built on top of `next/link`) to link to other pages. For example:
22 |
23 | ```tsx
24 | import { ProductDetail } from "@/routes";
25 |
26 | return (
27 | Product abc123
28 | );
29 | ```
30 |
31 | This is the equivalent of doing ` Product abc123` but with typesafety. And you never have to remember the URL. If the route moves, the typesafe route will be updated automatically.
32 |
33 | For APIs, use the exported `fetch` wrapping functions. For example:
34 |
35 | ```tsx
36 | import { useEffect } from "react";
37 | import { getProductInfo } from "@/routes";
38 |
39 | useEffect(() => {
40 | // Parameters are typed to the input of the API
41 | getProductInfo({ productId: "abc123" }).then((data) => {
42 | // Data is typed to the result of the API
43 | console.log(data);
44 | });
45 | }, []);
46 | ```
47 |
48 | This is the equivalent of doing `fetch('/api/product/abc123')` but with typesafety, and you never have to remember the URL. If the API moves, the typesafe route will be updated automatically.
49 |
50 | ## Using typed hooks
51 |
52 | The system provides three typed hooks to use in your application `usePush`, `useParams`, and `useSearchParams`.
53 |
54 | - `usePush` wraps the NextJS `useRouter` hook and returns a typed version of the `push` function.
55 | - `useParams` wraps `useNextParams` and returns the typed parameters for the route.
56 | - `useSearchParams` wraps `useNextSearchParams` and returns the typed search parameters for the route.
57 |
58 | For each hook you give the route to get the appropriate data back.
59 |
60 | ```ts
61 | import { Search } from "@/routes";
62 | import { useSearchParams } from "@/routes/hooks";
63 |
64 | export default MyClientComponent() {
65 | const searchParams = useSearchParams(Search);
66 | return {searchParams.query}
;
67 | }
68 | ```
69 |
70 | We had to extract the hooks into a seperate module because NextJS would not allow the routes to include hooks directly if
71 | they were used by React Server Components (RSCs).
72 |
73 | # Configure declarative-routing
74 |
75 | After running `npx declarative-routing init`, you don't need to configure anything to use it.
76 | However, you may want to customize some options to change the behavior of route generation.
77 |
78 | You can edit `declarative-routing.config.json` in the root of your project. The following options are available:
79 |
80 | - `mode`: choose between `react-router`, `nextjs` or `qwikcity`. It is automatically picked on init based on the project type.
81 | - `routes`: the directory where the routes are defined. It is picked from the initial wizard (and defaults to `./src/components/declarativeRoutes`).
82 | - `importPathPrefix`: the path prefix to add to the import path of the self-generated route objects, in order to be able to resolve them. It defaults to `@/app`.
83 |
84 | # When your routes change
85 |
86 | You'll need to run `pnpm dr:build` to update the generated files. This will update the types and the `@/routes` module to reflect the changes.
87 |
88 | The way the system works the `.info.ts` files are link to the `@/routes/index.ts` file. So changing the Zod schemas for the routes does **NOT** require a rebuild. You need to run the build command when:
89 |
90 | - You change the name of the route in the `.info.ts` file
91 | - You change the location of the route (e.g. `/product` to `/products`)
92 | - You change the parameters of the route (e.g. `/product/[id]` to `/product/[productId]`)
93 | - You add or remove routes
94 | - You add or remove verbs from API routes (e.g. adding `POST` to an existing route)
95 |
96 | You can also run the build command in watch mode using `pnpm dr:build:watch` but we don't recommend using that unless you are changing routes a lot. It's a neat party trick to change a route directory name and to watch the links automagically change with hot module reloading, but routes really don't change that much.
97 |
98 | # Finishing your setup
99 |
100 | Post setup there are some additional tasks that you need to complete to completely typesafe your routes. We've compiled a handy check list so you can keep track of your progress.
101 |
102 | - [ ] `/page.info.ts`: Add search typing to if the page supports search paramaters
103 | - [ ] Convert `Link` components for `/` to ``
104 | Once you've got that done you can remove this section.
105 |
106 | # Why is `makeRoute` copied into the `@/routes` module?
107 |
108 | You **own** this routing system once you install it. And we anticipate as part of that ownership you'll want to customize the routing system. That's why we create a `makeRoute.tsx` file in the `@/routes` module. This file is a copy of the `makeRoute.tsx` file from the `declarative-routing` package. You can modify this file to change the behavior of the routing system.
109 |
110 | For example, you might want to change the way `GET`, `POST`, `PUT`, and `DELETE` are handled. Or you might want to change the way the `Link` component works. You can do all of that by modifying the `makeRoute.tsx` file.
111 |
112 | We do **NOT** recommend changing the parameters of `makeRoute`, `makeGetRoute`, `makePostRoute`, `makePutRoute`, or `makeDeleteRoute` functions because that would cause incompatibility with the `build` command of `declarative-routing`.
113 |
114 | # Credit where credit is due
115 |
116 | This system is based on the work in [Fix Next.JS Routing To Have Full Type-Safety](https://www.flightcontrol.dev/blog/fix-nextjs-routing-to-have-full-type-safety). However the original article had a significantly different interface and didn't cover API routes at all.
117 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
6 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
7 |
8 | import { cn } from "@/lib/utils";
9 |
10 | function DropdownMenu({
11 | ...props
12 | }: React.ComponentProps) {
13 | return ;
14 | }
15 |
16 | function DropdownMenuPortal({
17 | ...props
18 | }: React.ComponentProps) {
19 | return (
20 |
21 | );
22 | }
23 |
24 | function DropdownMenuTrigger({
25 | ...props
26 | }: React.ComponentProps) {
27 | return (
28 |
32 | );
33 | }
34 |
35 | function DropdownMenuContent({
36 | className,
37 | sideOffset = 4,
38 | ...props
39 | }: React.ComponentProps) {
40 | return (
41 |
42 |
51 |
52 | );
53 | }
54 |
55 | function DropdownMenuGroup({
56 | ...props
57 | }: React.ComponentProps) {
58 | return (
59 |
60 | );
61 | }
62 |
63 | function DropdownMenuItem({
64 | className,
65 | inset,
66 | variant = "default",
67 | ...props
68 | }: React.ComponentProps & {
69 | inset?: boolean;
70 | variant?: "default" | "destructive";
71 | }) {
72 | return (
73 |
83 | );
84 | }
85 |
86 | function DropdownMenuCheckboxItem({
87 | className,
88 | children,
89 | checked,
90 | ...props
91 | }: React.ComponentProps) {
92 | return (
93 |
102 |
103 |
104 |
105 |
106 |
107 | {children}
108 |
109 | );
110 | }
111 |
112 | function DropdownMenuRadioGroup({
113 | ...props
114 | }: React.ComponentProps) {
115 | return (
116 |
120 | );
121 | }
122 |
123 | function DropdownMenuRadioItem({
124 | className,
125 | children,
126 | ...props
127 | }: React.ComponentProps) {
128 | return (
129 |
137 |
138 |
139 |
140 |
141 |
142 | {children}
143 |
144 | );
145 | }
146 |
147 | function DropdownMenuLabel({
148 | className,
149 | inset,
150 | ...props
151 | }: React.ComponentProps & {
152 | inset?: boolean;
153 | }) {
154 | return (
155 |
164 | );
165 | }
166 |
167 | function DropdownMenuSeparator({
168 | className,
169 | ...props
170 | }: React.ComponentProps) {
171 | return (
172 |
177 | );
178 | }
179 |
180 | function DropdownMenuShortcut({
181 | className,
182 | ...props
183 | }: React.ComponentProps<"span">) {
184 | return (
185 |
193 | );
194 | }
195 |
196 | function DropdownMenuSub({
197 | ...props
198 | }: React.ComponentProps) {
199 | return ;
200 | }
201 |
202 | function DropdownMenuSubTrigger({
203 | className,
204 | inset,
205 | children,
206 | ...props
207 | }: React.ComponentProps & {
208 | inset?: boolean;
209 | }) {
210 | return (
211 |
220 | {children}
221 |
222 |
223 | );
224 | }
225 |
226 | function DropdownMenuSubContent({
227 | className,
228 | ...props
229 | }: React.ComponentProps) {
230 | return (
231 |
239 | );
240 | }
241 |
242 | export {
243 | DropdownMenu,
244 | DropdownMenuPortal,
245 | DropdownMenuTrigger,
246 | DropdownMenuContent,
247 | DropdownMenuGroup,
248 | DropdownMenuLabel,
249 | DropdownMenuItem,
250 | DropdownMenuCheckboxItem,
251 | DropdownMenuRadioGroup,
252 | DropdownMenuRadioItem,
253 | DropdownMenuSeparator,
254 | DropdownMenuShortcut,
255 | DropdownMenuSub,
256 | DropdownMenuSubTrigger,
257 | DropdownMenuSubContent,
258 | };
259 |
--------------------------------------------------------------------------------
/src/routes/makeRoute.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */
2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
3 | /* eslint-disable @typescript-eslint/no-unsafe-return */
4 | import Link from "next/link";
5 |
6 | import queryString from "query-string";
7 | /*
8 | Derived from: https://www.flightcontrol.dev/blog/fix-nextjs-routing-to-have-full-type-safety
9 | */
10 | import { z } from "zod";
11 |
12 | type LinkProps = Parameters[0];
13 |
14 | export type RouteInfo<
15 | Params extends z.ZodSchema,
16 | Search extends z.ZodSchema,
17 | > = {
18 | name: string;
19 | params: Params;
20 | search: Search;
21 | description?: string;
22 | };
23 |
24 | export type GetInfo = {
25 | result: Result;
26 | };
27 |
28 | export type PostInfo = {
29 | body: Body;
30 | result: Result;
31 | description?: string;
32 | };
33 |
34 | export type PutInfo = {
35 | body: Body;
36 | result: Result;
37 | description?: string;
38 | };
39 |
40 | type FetchOptions = Parameters[1];
41 |
42 | type CoreRouteElements<
43 | Params extends z.ZodSchema,
44 | Search extends z.ZodSchema = typeof emptySchema,
45 | > = {
46 | params: z.output;
47 | paramsSchema: Params;
48 | search: z.output;
49 | searchSchema: Search;
50 | };
51 |
52 | type PutRouteBuilder<
53 | Params extends z.ZodSchema,
54 | Search extends z.ZodSchema,
55 | Body extends z.ZodSchema,
56 | Result extends z.ZodSchema,
57 | > = CoreRouteElements & {
58 | (
59 | body: z.input,
60 | p?: z.input,
61 | search?: z.input,
62 | options?: FetchOptions,
63 | ): Promise>;
64 |
65 | body: z.output;
66 | bodySchema: Body;
67 | result: z.output;
68 | resultSchema: Result;
69 | };
70 |
71 | type PostRouteBuilder<
72 | Params extends z.ZodSchema,
73 | Search extends z.ZodSchema,
74 | Body extends z.ZodSchema,
75 | Result extends z.ZodSchema,
76 | > = CoreRouteElements & {
77 | (
78 | body: z.input,
79 | p?: z.input,
80 | search?: z.input,
81 | options?: FetchOptions,
82 | ): Promise>;
83 |
84 | body: z.output;
85 | bodySchema: Body;
86 | result: z.output;
87 | resultSchema: Result;
88 | };
89 |
90 | type GetRouteBuilder<
91 | Params extends z.ZodSchema,
92 | Search extends z.ZodSchema,
93 | Result extends z.ZodSchema,
94 | > = CoreRouteElements & {
95 | (
96 | p?: z.input,
97 | search?: z.input,
98 | options?: FetchOptions,
99 | ): Promise>;
100 |
101 | result: z.output;
102 | resultSchema: Result;
103 | };
104 |
105 | type DeleteRouteBuilder<
106 | Params extends z.ZodSchema,
107 | Search extends z.ZodSchema,
108 | > = CoreRouteElements &
109 | ((
110 | p?: z.input,
111 | search?: z.input,
112 | options?: FetchOptions,
113 | ) => Promise);
114 |
115 | export type RouteBuilder<
116 | Params extends z.ZodSchema,
117 | Search extends z.ZodSchema,
118 | > = CoreRouteElements & {
119 | (p?: z.input, search?: z.input): string;
120 |
121 | routeName: string;
122 |
123 | Link: React.FC<
124 | Omit &
125 | z.input & {
126 | search?: z.input;
127 | } & { children?: React.ReactNode }
128 | >;
129 | ParamsLink: React.FC<
130 | Omit & {
131 | params?: z.input;
132 | search?: z.input;
133 | } & { children?: React.ReactNode }
134 | >;
135 | };
136 |
137 | function createPathBuilder>(
138 | route: string,
139 | ): (params: T) => string {
140 | const pathArr = route.split("/").filter(Boolean); // Remove empty strings from splitting
141 |
142 | let catchAllSegment: ((params: T) => string) | null = null;
143 | if (pathArr.at(-1)?.startsWith("[[...")) {
144 | // biome-ignore lint/style/noNonNullAssertion:
145 | const catchKey = pathArr.pop()!.replace("[[...", "").replace("]]", "");
146 | catchAllSegment = (params: T) => {
147 | const catchAll = params[catchKey] as unknown as string[];
148 | return catchAll ? `/${catchAll.join("/")}` : "";
149 | };
150 | }
151 |
152 | const elems: ((params: T) => string)[] = [];
153 | for (const elem of pathArr) {
154 | const catchAll = /\[\.\.\.(.*)\]/.exec(elem);
155 | const param = /\[(.*)\]/.exec(elem);
156 | if (catchAll?.[1]) {
157 | const key = catchAll[1];
158 | elems.push((params: T) =>
159 | (params[key as unknown as string] as string[]).join("/"),
160 | );
161 | } else if (param?.[1]) {
162 | const key = param[1];
163 | elems.push((params: T) => params[key as unknown as string] as string);
164 | } else if (!(elem.startsWith("(") && elem.endsWith(")"))) {
165 | elems.push(() => elem);
166 | }
167 | }
168 |
169 | return (params: T): string => {
170 | const segments = elems
171 | .map((e) => e(params))
172 | .filter(Boolean)
173 | .join("/");
174 |
175 | // Always ensure a leading slash
176 | const path = segments ? `/${segments}` : "/";
177 |
178 | if (catchAllSegment) {
179 | return path + catchAllSegment(params);
180 | }
181 | return path;
182 | };
183 | }
184 |
185 | function createRouteBuilder<
186 | Params extends z.ZodSchema,
187 | Search extends z.ZodSchema,
188 | >(route: string, info: RouteInfo) {
189 | const fn = createPathBuilder>(route);
190 |
191 | return (params?: z.input, search?: z.input) => {
192 | let checkedParams = params ?? {};
193 | if (info.params) {
194 | const safeParams = info.params.safeParse(checkedParams);
195 | if (!safeParams?.success) {
196 | throw new Error(
197 | `Invalid params for route ${info.name}: ${safeParams.error.message}`,
198 | );
199 | }
200 | checkedParams = safeParams.data;
201 | }
202 | const safeSearch = info.search
203 | ? info.search?.safeParse(search ?? {})
204 | : null;
205 | if (info.search && !safeSearch?.success) {
206 | throw new Error(
207 | `Invalid search params for route ${info.name}: ${safeSearch?.error.message}`,
208 | );
209 | }
210 |
211 | const baseUrl = fn(checkedParams);
212 | const searchString = search && queryString.stringify(search);
213 | return [baseUrl, searchString ? `?${searchString}` : ""].join("");
214 | };
215 | }
216 |
217 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
218 | const emptySchema = z.object({});
219 |
220 | export function makePostRoute<
221 | Params extends z.ZodSchema,
222 | Search extends z.ZodSchema,
223 | Body extends z.ZodSchema,
224 | Result extends z.ZodSchema,
225 | >(
226 | route: string,
227 | info: RouteInfo,
228 | postInfo: PostInfo,
229 | ): PostRouteBuilder {
230 | const urlBuilder = createRouteBuilder(route, info);
231 |
232 | const routeBuilder: PostRouteBuilder = (
233 | body: z.input,
234 | p?: z.input,
235 | search?: z.input,
236 | options?: FetchOptions,
237 | ): Promise> => {
238 | const safeBody = postInfo.body.safeParse(body);
239 | if (!safeBody.success) {
240 | throw new Error(
241 | `Invalid body for route ${info.name}: ${safeBody.error.message}`,
242 | );
243 | }
244 |
245 | return fetch(urlBuilder(p, search), {
246 | ...options,
247 | method: "POST",
248 | body: JSON.stringify(safeBody.data),
249 | headers: {
250 | ...(options?.headers ?? {}),
251 | "Content-Type": "application/json",
252 | },
253 | })
254 | .then((res) => {
255 | if (!res.ok) {
256 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`);
257 | }
258 | return res.json() as Promise>;
259 | })
260 | .then((data) => {
261 | const result = postInfo.result.safeParse(data);
262 | if (!result.success) {
263 | throw new Error(
264 | `Invalid response for route ${info.name}: ${result.error.message}`,
265 | );
266 | }
267 | return result.data;
268 | });
269 | };
270 |
271 | routeBuilder.params = undefined as z.output;
272 | routeBuilder.paramsSchema = info.params;
273 | routeBuilder.search = undefined as z.output;
274 | routeBuilder.searchSchema = info.search;
275 | routeBuilder.body = undefined as z.output;
276 | routeBuilder.bodySchema = postInfo.body;
277 | routeBuilder.result = undefined as z.output;
278 | routeBuilder.resultSchema = postInfo.result;
279 |
280 | return routeBuilder;
281 | }
282 |
283 | export function makePutRoute<
284 | Params extends z.ZodSchema,
285 | Search extends z.ZodSchema,
286 | Body extends z.ZodSchema,
287 | Result extends z.ZodSchema,
288 | >(
289 | route: string,
290 | info: RouteInfo,
291 | putInfo: PutInfo,
292 | ): PutRouteBuilder {
293 | const urlBuilder = createRouteBuilder(route, info);
294 |
295 | const routeBuilder: PutRouteBuilder = (
296 | body: z.input,
297 | p?: z.input,
298 | search?: z.input,
299 | options?: FetchOptions,
300 | ): Promise> => {
301 | const safeBody = putInfo.body.safeParse(body);
302 | if (!safeBody.success) {
303 | throw new Error(
304 | `Invalid body for route ${info.name}: ${safeBody.error.message}`,
305 | );
306 | }
307 |
308 | return fetch(urlBuilder(p, search), {
309 | ...options,
310 | method: "PUT",
311 | body: JSON.stringify(safeBody.data),
312 | headers: {
313 | ...(options?.headers ?? {}),
314 | "Content-Type": "application/json",
315 | },
316 | })
317 | .then((res) => {
318 | if (!res.ok) {
319 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`);
320 | }
321 | return res.json() as Promise>;
322 | })
323 | .then((data) => {
324 | const result = putInfo.result.safeParse(data);
325 | if (!result.success) {
326 | throw new Error(
327 | `Invalid response for route ${info.name}: ${result.error.message}`,
328 | );
329 | }
330 | return result.data;
331 | });
332 | };
333 |
334 | routeBuilder.params = undefined as z.output;
335 | routeBuilder.paramsSchema = info.params;
336 | routeBuilder.search = undefined as z.output;
337 | routeBuilder.searchSchema = info.search;
338 | routeBuilder.body = undefined as z.output;
339 | routeBuilder.bodySchema = putInfo.body;
340 | routeBuilder.result = undefined as z.output;
341 | routeBuilder.resultSchema = putInfo.result;
342 |
343 | return routeBuilder;
344 | }
345 |
346 | export function makeGetRoute<
347 | Params extends z.ZodSchema,
348 | Search extends z.ZodSchema,
349 | Result extends z.ZodSchema,
350 | >(
351 | route: string,
352 | info: RouteInfo,
353 | getInfo: GetInfo,
354 | ): GetRouteBuilder {
355 | const urlBuilder = createRouteBuilder(route, info);
356 |
357 | const routeBuilder: GetRouteBuilder = (
358 | p?: z.input,
359 | search?: z.input,
360 | options?: FetchOptions,
361 | ): Promise> => {
362 | return fetch(urlBuilder(p, search), options)
363 | .then((res) => {
364 | if (!res.ok) {
365 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`);
366 | }
367 | return res.json() as Promise>;
368 | })
369 | .then((data) => {
370 | const result = getInfo.result.safeParse(data);
371 | if (!result.success) {
372 | throw new Error(
373 | `Invalid response for route ${info.name}: ${result.error.message}`,
374 | );
375 | }
376 | return result.data;
377 | });
378 | };
379 |
380 | routeBuilder.params = undefined as z.output;
381 | routeBuilder.paramsSchema = info.params;
382 | routeBuilder.search = undefined as z.output;
383 | routeBuilder.searchSchema = info.search;
384 | routeBuilder.result = undefined as z.output;
385 | routeBuilder.resultSchema = getInfo.result;
386 |
387 | return routeBuilder;
388 | }
389 |
390 | export function makeDeleteRoute<
391 | Params extends z.ZodSchema,
392 | Search extends z.ZodSchema,
393 | >(
394 | route: string,
395 | info: RouteInfo,
396 | ): DeleteRouteBuilder {
397 | const urlBuilder = createRouteBuilder(route, info);
398 |
399 | const routeBuilder: DeleteRouteBuilder = (
400 | p?: z.input,
401 | search?: z.input,
402 | options?: FetchOptions,
403 | ): Promise => {
404 | return fetch(urlBuilder(p, search), {
405 | ...options,
406 | method: "DELETE",
407 | headers: {
408 | ...(options?.headers ?? {}),
409 | "Content-Type": "application/json",
410 | },
411 | }).then((res) => {
412 | if (!res.ok) {
413 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`);
414 | }
415 | });
416 | };
417 |
418 | routeBuilder.params = undefined as z.output;
419 | routeBuilder.paramsSchema = info.params;
420 | routeBuilder.search = undefined as z.output;
421 | routeBuilder.searchSchema = info.search;
422 |
423 | return routeBuilder;
424 | }
425 |
426 | export function makeRoute<
427 | Params extends z.ZodSchema,
428 | Search extends z.ZodSchema = typeof emptySchema,
429 | >(
430 | route: string,
431 | info: RouteInfo,
432 | ): RouteBuilder {
433 | const urlBuilder: RouteBuilder = createRouteBuilder(
434 | route,
435 | info,
436 | ) as RouteBuilder;
437 |
438 | urlBuilder.routeName = info.name;
439 |
440 | urlBuilder.ParamsLink = function RouteLink({
441 | params: linkParams,
442 | search: linkSearch,
443 | children,
444 | ...props
445 | }: Omit & {
446 | params?: z.input;
447 | search?: z.input;
448 | } & { children?: React.ReactNode }) {
449 | return (
450 |
451 | {children}
452 |
453 | );
454 | };
455 |
456 | urlBuilder.Link = function RouteLink({
457 | search: linkSearch,
458 | children,
459 | ...props
460 | }: Omit &
461 | z.input & {
462 | search?: z.input;
463 | } & { children?: React.ReactNode }) {
464 | const params = info.params.parse(props);
465 | const extraProps = { ...props };
466 | for (const key of Object.keys(params)) {
467 | delete extraProps[key];
468 | }
469 | return (
470 |
474 | {children}
475 |
476 | );
477 | };
478 |
479 | urlBuilder.params = undefined as z.output;
480 | urlBuilder.paramsSchema = info.params;
481 | urlBuilder.search = undefined as z.output;
482 | urlBuilder.searchSchema = info.search;
483 |
484 | return urlBuilder;
485 | }
486 |
--------------------------------------------------------------------------------