├── app ├── favicon.ico ├── app │ ├── page.tsx │ ├── home │ │ ├── _components │ │ │ └── random-toast.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── settings │ │ ├── page.tsx │ │ └── _components │ │ │ ├── notifications-page.tsx │ │ │ ├── appearance-page.tsx │ │ │ └── settings.tsx │ └── _components │ │ ├── app-sidebar.tsx │ │ └── user-btn.tsx ├── api │ ├── auth │ │ └── [...all] │ │ │ └── route.ts │ ├── create-customer │ │ └── route.ts │ ├── create-checkout-session │ │ └── route.ts │ ├── get-subscription │ │ └── route.ts │ └── webhooks │ │ └── dodo │ │ └── route.ts ├── loading.tsx ├── login │ ├── page.tsx │ └── sent │ │ └── page.tsx ├── signup │ ├── page.tsx │ └── sent │ │ └── page.tsx ├── page.tsx ├── _components │ ├── header-button.tsx │ ├── hero.tsx │ ├── features.tsx │ ├── footer.tsx │ └── header.tsx ├── not-found.tsx ├── layout.tsx └── pricing │ └── page.tsx ├── public ├── logo.png ├── orange-logo.png ├── images │ └── landing_ss.png └── dodo_payments_banner.jpg ├── types └── auth.ts ├── next.config.ts ├── .prettierrc ├── postcss.config.mjs ├── lib ├── utils.ts ├── email.tsx ├── auth │ ├── client.ts │ └── server.ts ├── metadata.ts ├── billing │ └── dodo.ts └── procedures.ts ├── .eslintrc.json ├── database ├── tables.ts ├── config.dev.ts ├── config.prod.ts ├── index.ts └── schema.ts ├── constants.ts ├── components ├── ui │ ├── skeleton.tsx │ ├── collapsible.tsx │ ├── label.tsx │ ├── separator.tsx │ ├── input.tsx │ ├── toaster.tsx │ ├── checkbox.tsx │ ├── switch.tsx │ ├── badge.tsx │ ├── tooltip.tsx │ ├── word-rotate.tsx │ ├── avatar.tsx │ ├── copy-button.tsx │ ├── rainbow-button.tsx │ ├── alert.tsx │ ├── password-input.tsx │ ├── tabs.tsx │ ├── button.tsx │ ├── card.tsx │ ├── breadcrumb.tsx │ ├── table.tsx │ ├── dialog.tsx │ ├── sheet.tsx │ ├── form.tsx │ ├── toast.tsx │ ├── select.tsx │ └── dropdown-menu.tsx ├── logo.tsx ├── custom-toaster.tsx ├── navigation-button.tsx ├── page-title.tsx ├── mode-toggle.tsx ├── cookie-consent.tsx └── forms │ ├── login-form.tsx │ └── signup-form.tsx ├── schemas └── auth.schema.ts ├── providers ├── theme-provider.tsx └── posthog-provider.tsx ├── components.json ├── hooks ├── use-router.ts ├── use-mobile.tsx ├── use-cached-session.ts └── use-toast.ts ├── .gitignore ├── .env.example ├── tsconfig.json ├── LICENSE ├── middleware.ts ├── global.d.ts ├── scripts └── restart-authentication.ts ├── env.js ├── README.md ├── package.json ├── styles └── globals.css ├── tailwind.config.ts └── emails └── magic-link.tsx /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrsrsh/startstack/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrsrsh/startstack/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/orange-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrsrsh/startstack/HEAD/public/orange-logo.png -------------------------------------------------------------------------------- /public/images/landing_ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrsrsh/startstack/HEAD/public/images/landing_ss.png -------------------------------------------------------------------------------- /public/dodo_payments_banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrsrsh/startstack/HEAD/public/dodo_payments_banner.jpg -------------------------------------------------------------------------------- /types/auth.ts: -------------------------------------------------------------------------------- 1 | import type { auth } from "../lib/auth/server"; 2 | 3 | export type Session = typeof auth.$Infer.Session; 4 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = {}; 4 | 5 | export default nextConfig; 6 | -------------------------------------------------------------------------------- /app/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function AppPage() { 4 | redirect("/app/home"); 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "plugins": ["prettier-plugin-tailwindcss"], 5 | "tailwindFunctions": ["cn", "clsx", "cva"] 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth/server"; 2 | import { toNextJsHandler } from "better-auth/next-js"; 3 | 4 | export const { GET, POST } = toNextJsHandler(auth.handler); 5 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"], 3 | "rules": { 4 | "@typescript-eslint/no-unused-vars": "off", 5 | "react/no-unescaped-entities": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /database/tables.ts: -------------------------------------------------------------------------------- 1 | import { 2 | accounts, 3 | sessions, 4 | users, 5 | verifications, 6 | } from "./schema"; 7 | 8 | export { 9 | accounts, 10 | sessions, 11 | users, 12 | verifications, 13 | }; 14 | -------------------------------------------------------------------------------- /constants.ts: -------------------------------------------------------------------------------- 1 | type PaymentProvider = "stripe" | "dodopayments" | "paddle"; 2 | 3 | export const APP_NAME = 4 | process.env.NODE_ENV === "development" ? "DEV - Startstack" : "Startstack"; 5 | export const PAYMENT_PROVIDER: PaymentProvider | "dodopayments" | "paddle" = 6 | "dodopayments"; 7 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /app/app/home/_components/random-toast.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "@/components/ui/button"; 3 | import { toast } from "sonner"; 4 | import React from "react"; 5 | 6 | export function RandomToast() { 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ); 13 | } 14 | 15 | export { Skeleton }; 16 | -------------------------------------------------------------------------------- /database/config.dev.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | import env from "@/env"; 3 | 4 | export default defineConfig({ 5 | dialect: "postgresql", 6 | schema: "./database/schema.ts", 7 | out: "./database/migrations/development", 8 | verbose: true, 9 | dbCredentials: { 10 | url: env.DATABASE_URL_DEVELOPMENT, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /database/config.prod.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | import env from "@/env"; 3 | 4 | export default defineConfig({ 5 | dialect: "postgresql", 6 | schema: "./database/schema.ts", 7 | out: "./database/migrations/production", 8 | verbose: true, 9 | dbCredentials: { 10 | url: env.DATABASE_URL_PRODUCTION, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /schemas/auth.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const loginSchema = z.object({ 4 | email: z.string().email({ message: "Please enter a valid email address" }), 5 | }); 6 | 7 | export const signUpSchema = z.object({ 8 | fullName: z.string().min(2, { message: "Full name is required" }), 9 | email: z.string().email({ message: "Please enter a valid email address" }), 10 | }); 11 | -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; 4 | 5 | const Collapsible = CollapsiblePrimitive.Root; 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }; 12 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "@/components/forms/login-form"; 2 | import { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Login", 6 | description: "Create an account", 7 | }; 8 | 9 | export default async function LoginPage() { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignupForm } from "@/components/forms/signup-form"; 2 | import { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Sign Up", 6 | description: "Create an account", 7 | }; 8 | 9 | export default async function SignUpPage() { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from "./_components/header"; 2 | import { Footer } from "./_components/footer"; 3 | import { Features } from "./_components/features"; 4 | import { Hero } from "./_components/hero"; 5 | 6 | export default async function RootPage() { 7 | return ( 8 |
9 |
10 | 11 | 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /components/logo.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Cuboid, GitGraph } from "lucide-react"; 3 | import React from "react"; 4 | 5 | export function Logo({ className }: { className?: string }) { 6 | return ( 7 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import dynamic from "next/dynamic"; 5 | import { type ThemeProviderProps } from "next-themes"; 6 | 7 | const NextThemesProvider = dynamic( 8 | () => import("next-themes").then((e) => e.ThemeProvider), 9 | { 10 | ssr: false, 11 | }, 12 | ); 13 | 14 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 15 | return {children}; 16 | } 17 | -------------------------------------------------------------------------------- /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": "styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AppSidebar } from "./_components/app-sidebar"; 2 | import { Suspense } from "react"; 3 | import Loading from "@/app/loading"; 4 | import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; 5 | 6 | export default async function AppLayout({ 7 | children, 8 | }: Readonly<{ 9 | children: React.ReactNode; 10 | }>) { 11 | return ( 12 | 13 | 14 | 15 | }>{children} 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /hooks/use-router.ts: -------------------------------------------------------------------------------- 1 | import { useRouter as useToploaderRouter } from "nextjs-toploader/app"; 2 | import { useRouter as useDefaultRouter } from "next/navigation"; 3 | 4 | export const useRouter = ({ 5 | topLoader = true, 6 | }: { topLoader?: boolean } = {}) => { 7 | const nextRouter = useDefaultRouter(); 8 | const toploaderRouter = useToploaderRouter(); 9 | 10 | // Return nextRouter immediately if animation is false 11 | if (!topLoader) { 12 | return nextRouter; 13 | } 14 | 15 | // Return appropriate router based on device capabilities and animation enabled 16 | return toploaderRouter; 17 | }; 18 | -------------------------------------------------------------------------------- /components/custom-toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | // import { usePathname } from "next/navigation"; 5 | import { Toaster, ToasterProps } from "sonner"; 6 | 7 | type Theme = "light" | "dark" | "system"; 8 | 9 | export function CustomToaster(props: ToasterProps) { 10 | const { theme } = useTheme(); 11 | // const pathname = usePathname(); 12 | return ( 13 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState( 7 | undefined, 8 | ); 9 | 10 | React.useEffect(() => { 11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 12 | const onChange = () => { 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 14 | }; 15 | mql.addEventListener("change", onChange); 16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 17 | return () => mql.removeEventListener("change", onChange); 18 | }, []); 19 | 20 | return !!isMobile; 21 | } 22 | -------------------------------------------------------------------------------- /app/app/home/page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react"; 2 | import { PageTitle } from "@/components/page-title"; 3 | import { Metadata } from "next"; 4 | import { RandomToast } from "./_components/random-toast"; 5 | import Loading from "@/app/loading"; 6 | 7 | export const metadata: Metadata = { 8 | title: { 9 | default: "Home", 10 | template: "%s | Home", 11 | }, 12 | }; 13 | 14 | export default function HomeRoute() { 15 | return ( 16 |
17 | 18 | }> 19 |
20 | 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /lib/email.tsx: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | 3 | import env from "@/env"; 4 | import { ReactNode } from "react"; 5 | import { APP_NAME } from "@/constants"; 6 | const resend = new Resend(env.RESEND_API_KEY); 7 | 8 | const DEFAULT_SENDER_NAME = APP_NAME; 9 | 10 | // Replace with your email and sender name 11 | const DEFAULT_EMAIL = "noreply@ascendifyr.in"; 12 | 13 | export async function sendEmail( 14 | email: string, 15 | subject: string, 16 | body: ReactNode, 17 | ) { 18 | const { error } = await resend.emails.send({ 19 | from: `${DEFAULT_SENDER_NAME} <${DEFAULT_EMAIL}>`, 20 | to: email, 21 | subject, 22 | react: <>{body}, 23 | }); 24 | 25 | if (error) { 26 | throw error; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.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 | 32 | # env files (can opt-in for committing if needed) 33 | .env.local* 34 | .env.development* 35 | .env.test* 36 | .env.production* 37 | .env 38 | 39 | # vercel 40 | .vercel 41 | 42 | # database 43 | /database/migrations 44 | 45 | # typescript 46 | *.tsbuildinfo 47 | next-env.d.ts 48 | -------------------------------------------------------------------------------- /app/_components/header-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | 4 | interface HeaderButtonProps { 5 | href: string; 6 | label: string; 7 | icon?: React.ReactNode; 8 | } 9 | 10 | export function HeaderButton({ href, label, icon }: HeaderButtonProps) { 11 | return ( 12 | 17 | {icon} 18 | {label} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /lib/auth/client.ts: -------------------------------------------------------------------------------- 1 | import env from "@/env"; 2 | import { 3 | magicLinkClient, 4 | organizationClient, 5 | } from "better-auth/client/plugins"; 6 | import { createAuthClient } from "better-auth/react"; 7 | 8 | export const authClient = createAuthClient({ 9 | baseURL: env.NEXT_PUBLIC_APP_URL, 10 | plugins: [magicLinkClient()], 11 | }); 12 | 13 | export const { 14 | signIn, 15 | signOut, 16 | signUp, 17 | revokeSession, 18 | updateUser, 19 | getSession, 20 | magicLink, 21 | changePassword, 22 | resetPassword, 23 | sendVerificationEmail, 24 | changeEmail, 25 | deleteUser, 26 | linkSocial, 27 | forgetPassword, 28 | useSession, 29 | verifyEmail, 30 | listAccounts, 31 | listSessions, 32 | revokeOtherSessions, 33 | revokeSessions, 34 | } = authClient; 35 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeft, TriangleAlert, Frown } from "lucide-react"; 2 | import { Link } from "next-view-transitions"; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | 8 |

9 | 10 | Uh, oh. We couldn't find that page. 11 |

12 | 17 | 18 | Return Home 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react"; 2 | import { auth } from "@/lib/auth/server"; 3 | import { headers } from "next/headers"; 4 | import { PageTitle } from "@/components/page-title"; 5 | import { Settings } from "./_components/settings"; 6 | import Loading from "@/app/loading"; 7 | 8 | export default async function SettingsPage() { 9 | const session = await auth.api.getSession({ 10 | headers: await headers(), 11 | }); 12 | const activeSessions = await auth.api.listSessions({ 13 | headers: await headers(), 14 | }); 15 | 16 | return ( 17 |
18 | 19 | }> 20 | 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database URLs 2 | DATABASE_URL_DEVELOPMENT="postgres://:@:/" 3 | DATABASE_URL_PRODUCTION="postgres://:@:/ only required if you want to deploy to production" 4 | 5 | # Application settings 6 | NEXT_PUBLIC_APP_URL="required" 7 | NEXT_PUBLIC_CACHE_ENCRYPTION_KEY="required" 8 | 9 | # Authentication credentials 10 | GOOGLE_CLIENT_ID="optional" 11 | GOOGLE_CLIENT_SECRET="optional" 12 | GITHUB_CLIENT_ID="optional" 13 | GITHUB_CLIENT_SECRET="optional" 14 | BETTER_AUTH_SECRET="required" 15 | 16 | # API keys 17 | RESEND_API_KEY="required" 18 | 19 | # Analytics 20 | POSTHOG_KEY="optional" 21 | POSTHOG_HOST="optional" 22 | 23 | 24 | # Payments 25 | # DODO_API_KEY="required" 26 | DODO_ENVIRONMENT=test_mode 27 | DODO_API_KEY=7IaVMF****************************** 28 | DODO_WEBHOOK_SECRET=whsec_DL2************************ -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref, 15 | ) => ( 16 | 27 | ), 28 | ); 29 | Separator.displayName = SeparatorPrimitive.Root.displayName; 30 | 31 | export { Separator }; 32 | -------------------------------------------------------------------------------- /lib/metadata.ts: -------------------------------------------------------------------------------- 1 | import { APP_NAME } from "@/constants"; 2 | import env from "@/env"; 3 | import type { Metadata } from "next/types"; 4 | 5 | export function createMetadata(override: Metadata): Metadata { 6 | return { 7 | ...override, 8 | openGraph: { 9 | title: override.title ?? undefined, 10 | description: override.description ?? undefined, 11 | url: env.NEXT_PUBLIC_APP_URL, 12 | images: "https://demo.better-auth.com/og.png", 13 | siteName: APP_NAME, 14 | ...override.openGraph, 15 | }, 16 | twitter: { 17 | card: "summary_large_image", 18 | creator: "@warisareshi", 19 | title: override.title ?? undefined, 20 | description: override.description ?? undefined, 21 | images: "https://demo.better-auth.com/og.png", 22 | ...override.twitter, 23 | }, 24 | metadataBase: override.metadataBase ?? new URL(env.NEXT_PUBLIC_APP_URL), 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /providers/posthog-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import env from "@/env"; 3 | import posthog from "posthog-js"; 4 | import { PostHogProvider as PGP } from "posthog-js/react"; 5 | import { useEffect } from "react"; 6 | import React from "react"; 7 | 8 | export function PostHogProvider({ children }: { children: React.ReactNode }) { 9 | useEffect(() => { 10 | if (!env.NEXT_PUBLIC_POSTHOG_API_KEY) return; 11 | posthog.init(env.NEXT_PUBLIC_POSTHOG_API_KEY, { 12 | api_host: env.NEXT_PUBLIC_POSTHOG_HOST, 13 | secure_cookie: process.env.NODE_ENV === "production", 14 | person_profiles: "identified_only", 15 | capture_pageview: false, // Disable automatic pageview capture, as we capture manually 16 | }); 17 | }, []); 18 | 19 | if (!env.NEXT_PUBLIC_POSTHOG_API_KEY || !env.NEXT_PUBLIC_POSTHOG_HOST) { 20 | return <>{children}; 21 | } 22 | return {children}; 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./*" 28 | ], 29 | "env": [ 30 | "./env.js" 31 | ] 32 | } 33 | }, 34 | "include": [ 35 | "next-env.d.ts", 36 | "**/*.ts", 37 | "**/*.tsx", 38 | ".next/types/**/*.ts", 39 | "app/app/home/_components/random-toast.tsx" 40 | ], 41 | "exclude": [ 42 | "node_modules" 43 | ] 44 | } -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ); 18 | }, 19 | ); 20 | Input.displayName = "Input"; 21 | 22 | type InputProps = React.ComponentProps<"input">; 23 | 24 | export { Input, type InputProps }; 25 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useToast } from "@/hooks/use-toast"; 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast"; 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast(); 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ); 31 | })} 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /database/index.ts: -------------------------------------------------------------------------------- 1 | import * as tables from "./tables"; 2 | import { drizzle as drizzleDevelopment } from "drizzle-orm/postgres-js"; 3 | import { drizzle as drizzleProduction } from "drizzle-orm/neon-http"; 4 | import postgres from "postgres"; 5 | import { neon } from "@neondatabase/serverless"; 6 | import env from "@/env"; 7 | 8 | // Determine the database URL based on the environment 9 | const databaseUrl = 10 | process.env.NODE_ENV === "development" 11 | ? env.DATABASE_URL_DEVELOPMENT 12 | : env.DATABASE_URL_PRODUCTION; 13 | 14 | // Set up the SQL client depending on the environment 15 | const sql = 16 | process.env.NODE_ENV === "development" ? postgres(databaseUrl) : neon(databaseUrl); 17 | 18 | // Initialize the database with the appropriate drizzle function and schema 19 | export const db = 20 | process.env.NODE_ENV === "development" 21 | ? drizzleDevelopment(sql as any, { schema: { ...tables } }) 22 | : drizzleProduction({ 23 | client: sql as any, 24 | schema: { ...tables }, 25 | }); 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2024 Startstack 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { betterFetch } from "@better-fetch/fetch"; 2 | import { Session } from "better-auth"; 3 | import { NextResponse, type NextRequest } from "next/server"; 4 | 5 | export default async function authMiddleware(request: NextRequest) { 6 | const isAuthPage = 7 | request.nextUrl.pathname.startsWith("/login") || 8 | request.nextUrl.pathname.startsWith("/signup"); 9 | const isAppPage = request.nextUrl.pathname.startsWith("/app/"); 10 | 11 | const { data: session } = await betterFetch( 12 | "/api/auth/get-session", 13 | { 14 | baseURL: request.nextUrl.origin, 15 | headers: { 16 | cookie: request.headers.get("cookie") || "", 17 | }, 18 | }, 19 | ); 20 | 21 | if (session && isAuthPage) { 22 | return NextResponse.redirect(new URL("/app/home", request.url)); 23 | } 24 | 25 | if (!session && isAppPage) { 26 | return NextResponse.redirect(new URL("/login", request.url)); 27 | } 28 | 29 | return NextResponse.next(); 30 | } 31 | 32 | export const config = { 33 | matcher: ["/app/:path*", "/login", "/signup", "/signup/sent", "/login/sent"], 34 | }; 35 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 5 | import { Check } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )); 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /lib/billing/dodo.ts: -------------------------------------------------------------------------------- 1 | import env from "@/env"; 2 | import DodoPayments from "dodopayments"; 3 | import { Payment as BasePayment } from "dodopayments/resources/payments.mjs"; 4 | import { Subscription as BaseSubscription } from "dodopayments/resources/subscriptions.mjs"; 5 | 6 | export const dodo = new DodoPayments({ 7 | bearerToken: env.DODO_API_KEY, // This is the default and can be omitted 8 | environment: "test_mode", // defaults to 'live_mode' 9 | }); 10 | 11 | export type Payment = BasePayment & { payload_type: string }; 12 | export type Subscription = BaseSubscription & { payload_type: string }; 13 | 14 | export type OneTimeProduct = { 15 | product_id: string; 16 | quantity: number; 17 | }; 18 | 19 | export type SubscriptionDetails = { 20 | activated_at: string; 21 | subscription_id: string; 22 | payment_frequency_interval: "Day" | "Week" | "Month" | "Year"; 23 | product_id: string; 24 | }; 25 | 26 | export type WebhookPayload = { 27 | type: string; 28 | data: Payment | Subscription; 29 | }; 30 | 31 | export interface UpdateSubscriptionResult { 32 | success: boolean; 33 | error?: { 34 | message: string; 35 | status: number; 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /components/navigation-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button, ButtonProps, buttonVariants } from "@/components/ui/button"; 3 | import { cn } from "@/lib/utils"; 4 | import { Loader2 } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | import { useState } from "react"; 7 | import { toast } from "sonner"; 8 | 9 | interface NavigationButtonProps extends ButtonProps { 10 | href: string; 11 | } 12 | 13 | export function NavigationButton({ 14 | href, 15 | disabled, 16 | className, 17 | variant, 18 | children, 19 | }: NavigationButtonProps) { 20 | const [loading, setLoading] = useState(false); 21 | const router = useRouter(); 22 | return ( 23 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider; 9 | 10 | const Tooltip = TooltipPrimitive.Root; 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger; 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 19 | 28 | 29 | )); 30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 31 | 32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 33 | -------------------------------------------------------------------------------- /components/ui/word-rotate.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { AnimatePresence, HTMLMotionProps, motion } from "framer-motion"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | interface WordRotateProps { 9 | words: string[]; 10 | duration?: number; 11 | framerProps?: HTMLMotionProps<"h1">; 12 | className?: string; 13 | } 14 | 15 | export default function WordRotate({ 16 | words, 17 | duration = 2500, 18 | framerProps = { 19 | initial: { opacity: 0, y: -50 }, 20 | animate: { opacity: 1, y: 0 }, 21 | exit: { opacity: 0, y: 50 }, 22 | transition: { duration: 0.25, ease: "easeOut" }, 23 | }, 24 | className, 25 | }: WordRotateProps) { 26 | const [index, setIndex] = useState(0); 27 | 28 | useEffect(() => { 29 | const interval = setInterval(() => { 30 | setIndex((prevIndex) => (prevIndex + 1) % words.length); 31 | }, duration); 32 | 33 | // Clean up interval on unmount 34 | return () => clearInterval(interval); 35 | }, [words, duration]); 36 | 37 | return ( 38 |
39 | 40 | 45 | {words[index]} 46 | 47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import { AnimationControls } from "framer-motion"; 2 | import React from "react"; 3 | 4 | declare module "framer-motion" { 5 | type PrimitiveMotionValue = number | string | number[] | string[]; 6 | type NestedMotionValue = { 7 | [key: string]: PrimitiveMotionValue | NestedMotionValue; 8 | }; 9 | type MotionValue = PrimitiveMotionValue | NestedMotionValue; 10 | 11 | interface TransitionProps { 12 | duration?: number; 13 | delay?: number; 14 | ease?: string | number[]; 15 | repeat?: number; 16 | repeatType?: string; 17 | repeatDelay?: number; 18 | type?: string; 19 | stiffness?: number; 20 | damping?: number; 21 | mass?: number; 22 | velocity?: number; 23 | times?: number[]; 24 | delayChildren?: number; 25 | staggerChildren?: number; 26 | when?: string | boolean; 27 | } 28 | 29 | interface MotionProps { 30 | className?: string; 31 | animate?: AnimationControls | MotionValue; 32 | onClick?: () => void; 33 | id?: string | (() => string); 34 | style?: React.CSSProperties; 35 | children?: React.ReactNode; 36 | initial?: boolean | string | Record; 37 | animate?: string | Record; 38 | exit?: string | Record; 39 | transition?: TransitionProps; 40 | variants?: { 41 | [key: string]: { 42 | [key: string]: MotionValue | TransitionProps; 43 | }; 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/_components/hero.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import { GithubIcon } from "lucide-react"; 5 | import Link from "next/link"; 6 | 7 | export function Hero() { 8 | return ( 9 |
10 |
11 | 12 | 16 | 17 |

18 | Fork the repo to get started. 19 |

20 |
21 | 22 | 23 | {/* Heading with Orange Pill */} 24 |

25 | The{" "} 26 | 27 | easiest way 28 | {" "} 29 | to get started with your next saas project. 30 |

31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /app/login/sent/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { CheckCircle } from "lucide-react"; 3 | import { Metadata } from "next"; 4 | import Link from "next/link"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Login Link Sent", 8 | }; 9 | 10 | export default async function MagicLinkSent() { 11 | return ( 12 |
13 | 14 |
15 | 16 | 17 | 18 | Login Link Sent! 19 | 20 | 21 | 22 |

23 | We've sent a magic link to your email address, click the continue 24 | button in the email to login. If you don't see the email in your 25 | inbox, check your spam folder. 26 |

27 |

28 | Still didn't receive the email?{" "} 29 | 33 | Try Again 34 | 35 |

36 |
37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/signup/sent/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { CheckCircle } from "lucide-react"; 3 | import { Metadata } from "next"; 4 | import Link from "next/link"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Sign Up Link Sent", 8 | }; 9 | 10 | export default async function MagicLinkSent() { 11 | return ( 12 |
13 | 14 |
15 | 16 | 17 | 18 | Sign Up Link Sent! 19 | 20 | 21 | 22 |

23 | We've sent a magic link to your email address, click the continue 24 | button in the email to signup. If you don't see the email in your 25 | inbox, check your spam folder. 26 |

27 |

28 | Still didn't receive the email?{" "} 29 | 33 | Try Again 34 | 35 |

36 |
37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /components/ui/copy-button.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Copy, Check } from "lucide-react"; 4 | import { 5 | Tooltip, 6 | TooltipContent, 7 | TooltipProvider, 8 | TooltipTrigger, 9 | } from "@/components/ui/tooltip"; 10 | 11 | interface CopyButtonProps { 12 | textToCopy: string; 13 | } 14 | 15 | export default function CopyButton({ textToCopy }: CopyButtonProps) { 16 | const [isCopied, setIsCopied] = useState(false); 17 | 18 | useEffect(() => { 19 | if (isCopied) { 20 | const timer = setTimeout(() => setIsCopied(false), 2000); 21 | return () => clearTimeout(timer); 22 | } 23 | }, [isCopied]); 24 | 25 | const handleCopy = async () => { 26 | try { 27 | await navigator.clipboard.writeText(textToCopy); 28 | setIsCopied(true); 29 | } catch (err) { 30 | console.error("Failed to copy text: ", err); 31 | } 32 | }; 33 | 34 | return ( 35 | 36 | 37 | 38 | 51 | 52 | 53 |

{isCopied ? "Copied!" : "Copy to clipboard"}

54 |
55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /components/page-title.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Breadcrumb, 3 | BreadcrumbList, 4 | BreadcrumbItem, 5 | BreadcrumbLink, 6 | BreadcrumbPage, 7 | BreadcrumbSeparator, 8 | } from "@/components/ui/breadcrumb"; 9 | import { Separator } from "@/components/ui/separator"; 10 | import { SidebarTrigger } from "./ui/sidebar"; 11 | 12 | interface PageTitleProps { 13 | selfLabel: string; 14 | parentLabel?: string; 15 | parentUrl?: string; 16 | triggerDisabled?: boolean; 17 | } 18 | 19 | export function PageTitle({ 20 | parentLabel, 21 | parentUrl, 22 | selfLabel, 23 | triggerDisabled = false, 24 | }: PageTitleProps) { 25 | return ( 26 |
27 |
28 | {!triggerDisabled && ( 29 | <> 30 | 31 | 32 | 33 | )} 34 | 35 | 36 | {parentLabel && ( 37 | <> 38 | 39 | 40 | {parentLabel} 41 | 42 | 43 | 44 | 45 | )} 46 | 47 | {selfLabel} 48 | 49 | 50 | 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/ui/rainbow-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export function RainbowButton({ 6 | children, 7 | className, 8 | ...props 9 | }: React.ButtonHTMLAttributes) { 10 | return ( 11 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /lib/auth/server.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { magicLink } from "better-auth/plugins"; 3 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 4 | import * as tables from "@/database/tables"; 5 | import env from "env"; 6 | import { db } from "@/database"; 7 | import { sendMagicLink } from "@/emails/magic-link"; 8 | import { APP_NAME } from "@/constants"; 9 | 10 | export const auth = betterAuth({ 11 | appName: APP_NAME, 12 | baseURL: env.NEXT_PUBLIC_APP_URL, 13 | secret: env.BETTER_AUTH_SECRET, 14 | trustedOrigins: [env.NEXT_PUBLIC_APP_URL], 15 | logger: { 16 | disabled: process.env.NODE_ENV === "production", 17 | level: "debug", 18 | }, 19 | session: { 20 | expiresIn: 60 * 60 * 24 * 30, // 30 days 21 | cookieCache: { 22 | enabled: false, 23 | }, 24 | }, 25 | socialProviders: { 26 | google: { 27 | clientId: env.GOOGLE_CLIENT_ID as string, 28 | clientSecret: env.GOOGLE_CLIENT_SECRET as string, 29 | }, 30 | github: { 31 | clientId: env.GITHUB_CLIENT_ID as string, 32 | clientSecret: env.GITHUB_CLIENT_SECRET as string, 33 | }, 34 | }, 35 | database: drizzleAdapter(db, { 36 | provider: "pg", 37 | schema: { 38 | ...tables, 39 | // ...relations, 40 | }, 41 | usePlural: true, 42 | }), 43 | account: { 44 | accountLinking: { 45 | enabled: true, 46 | trustedProviders: ["google", "github"], 47 | }, 48 | }, 49 | plugins: [ 50 | magicLink({ 51 | sendMagicLink: async ({ email, url }, request) => { 52 | if (process.env.NODE_ENV === "development") { 53 | console.log("✨ Magic link: " + url); 54 | } 55 | await sendMagicLink(email, url); 56 | }, 57 | }), 58 | ], 59 | }); 60 | -------------------------------------------------------------------------------- /scripts/restart-authentication.ts: -------------------------------------------------------------------------------- 1 | import env from "@/env"; 2 | import postgres from "postgres"; 3 | 4 | // THIS SCRIPT CAN AND SHOULD BE ONLY EXECUTED IN DEVELOPMENT ENVIRONMENTS 5 | 6 | const TABLES = [ 7 | "users", 8 | "accounts", 9 | "sessions", 10 | "verifications", 11 | "organizations", 12 | "members", 13 | "invitations", 14 | ]; 15 | 16 | async function main() { 17 | // Create postgres connection 18 | const sql = postgres(env.DATABASE_URL_DEVELOPMENT, { 19 | // Ensure we're in a development environment 20 | max: 1, // Use a single connection for this script 21 | idle_timeout: 20, // Close connection after 20 seconds of inactivity 22 | }); 23 | 24 | // Confirmation prompt 25 | const confirmation = prompt(` 26 | Are you sure you want to truncate the following tables? 27 | ${TABLES.join(", ")} 28 | 29 | This will delete all rows, reset the identity, and cascade where applicable. 30 | Type "yes" to confirm: 31 | `); 32 | 33 | if (confirmation?.toLowerCase() === "yes") { 34 | try { 35 | for (const table of TABLES) { 36 | const query = `TRUNCATE TABLE ${table} RESTART IDENTITY CASCADE;`; 37 | await sql.unsafe(query); 38 | console.log(`✅ Table "${table}" truncated successfully!`); 39 | } 40 | console.log("\n✨ All tables truncated successfully!"); 41 | } catch (error) { 42 | console.error("❌ Error truncating tables:", error); 43 | process.exit(1); 44 | } finally { 45 | // Ensure we close the connection 46 | await sql.end(); 47 | process.exit(0); 48 | } 49 | } else { 50 | console.log("❌ Truncation canceled."); 51 | process.exit(0); 52 | } 53 | } 54 | 55 | // Run the script 56 | main().catch((error) => { 57 | console.error("❌ Unhandled error:", error); 58 | process.exit(1); 59 | }); 60 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | }, 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )); 33 | Alert.displayName = "Alert"; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )); 45 | AlertTitle.displayName = "AlertTitle"; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )); 57 | AlertDescription.displayName = "AlertDescription"; 58 | 59 | export { Alert, AlertTitle, AlertDescription }; 60 | -------------------------------------------------------------------------------- /lib/procedures.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSafeActionClient, 3 | DEFAULT_SERVER_ERROR_MESSAGE, 4 | } from "next-safe-action"; 5 | import { headers } from "next/headers"; 6 | import { z } from "zod"; 7 | import { auth } from "./auth/server"; 8 | import { APP_NAME } from "@/constants"; 9 | 10 | const actionClient = createSafeActionClient({ 11 | handleServerError(e) { 12 | console.error("Action error:", e.message); 13 | 14 | if (e instanceof Error) { 15 | return e.message; 16 | } 17 | 18 | return DEFAULT_SERVER_ERROR_MESSAGE; 19 | }, 20 | defineMetadataSchema() { 21 | return z.object({ 22 | actionName: z.string(), 23 | }); 24 | }, 25 | }).use(async ({ next, clientInput, metadata }) => { 26 | const startTime = performance.now(); 27 | 28 | const result = await next(); 29 | 30 | const endTime = performance.now(); 31 | 32 | console.log(`Server action ${metadata.actionName} 33 | with input: 34 | ${clientInput} took ${endTime - startTime}ms 35 | and resulted with: 36 | ${result}`); 37 | 38 | return result; 39 | }); 40 | 41 | export const authActionClient = actionClient 42 | .use(async ({ next }) => { 43 | const res = await auth.api.getSession({ 44 | headers: await headers(), 45 | }); 46 | 47 | if (!res || !res.session || !res.user) { 48 | throw new Error("You are not authorized to perform this action"); 49 | } 50 | const extraUtils = { 51 | authenticatedUrl: "/app/home", 52 | unauthenticatedUrl: "/login", 53 | appName: APP_NAME, 54 | }; 55 | return next({ 56 | ctx: { 57 | user: res.user, 58 | session: res.session, 59 | utils: extraUtils, 60 | }, 61 | }); 62 | }) 63 | .outputSchema( 64 | z.object({ 65 | success: z.boolean(), 66 | message: z.string(), 67 | data: z.any(), 68 | }), 69 | ); 70 | -------------------------------------------------------------------------------- /database/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pgTable, 3 | text, 4 | integer, 5 | timestamp, 6 | boolean, 7 | } from "drizzle-orm/pg-core"; 8 | 9 | export const users = pgTable("users", { 10 | id: text("id").primaryKey(), 11 | name: text("name").notNull(), 12 | email: text("email").notNull().unique(), 13 | emailVerified: boolean("emailVerified").notNull(), 14 | image: text("image"), 15 | createdAt: timestamp("createdAt").notNull(), 16 | updatedAt: timestamp("updatedAt").notNull(), 17 | }); 18 | 19 | export const sessions = pgTable("sessions", { 20 | id: text("id").primaryKey(), 21 | expiresAt: timestamp("expiresAt").notNull(), 22 | token: text("token").notNull().unique(), 23 | createdAt: timestamp("createdAt").notNull(), 24 | updatedAt: timestamp("updatedAt").notNull(), 25 | ipAddress: text("ipAddress"), 26 | userAgent: text("userAgent"), 27 | userId: text("userId") 28 | .notNull() 29 | .references(() => users.id), 30 | }); 31 | 32 | export const accounts = pgTable("accounts", { 33 | id: text("id").primaryKey(), 34 | accountId: text("accountId").notNull(), 35 | providerId: text("providerId").notNull(), 36 | userId: text("userId") 37 | .notNull() 38 | .references(() => users.id), 39 | accessToken: text("accessToken"), 40 | refreshToken: text("refreshToken"), 41 | idToken: text("idToken"), 42 | accessTokenExpiresAt: timestamp("accessTokenExpiresAt"), 43 | refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"), 44 | scope: text("scope"), 45 | password: text("password"), 46 | createdAt: timestamp("createdAt").notNull(), 47 | updatedAt: timestamp("updatedAt").notNull(), 48 | }); 49 | 50 | export const verifications = pgTable("verifications", { 51 | id: text("id").primaryKey(), 52 | identifier: text("identifier").notNull(), 53 | value: text("value").notNull(), 54 | expiresAt: timestamp("expiresAt").notNull(), 55 | createdAt: timestamp("createdAt"), 56 | updatedAt: timestamp("updatedAt"), 57 | }); 58 | -------------------------------------------------------------------------------- /components/ui/password-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { EyeIcon, EyeOffIcon } from "lucide-react"; 4 | import * as React from "react"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Input, type InputProps } from "@/components/ui/input"; 8 | import { cn } from "@/lib/utils"; 9 | 10 | const PasswordInput = React.forwardRef( 11 | ({ className, ...props }, ref) => { 12 | const [showPassword, setShowPassword] = React.useState(false); 13 | const disabled = 14 | props.value === "" || props.value === undefined || props.disabled; 15 | 16 | return ( 17 |
18 | 25 | 42 | 43 | {/* hides browsers password toggles */} 44 | 52 |
53 | ); 54 | }, 55 | ); 56 | PasswordInput.displayName = "PasswordInput"; 57 | 58 | export { PasswordInput }; 59 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "@/styles/globals.css"; 3 | import { ThemeProvider } from "@/providers/theme-provider"; 4 | import { NuqsAdapter } from "nuqs/adapters/next/app"; 5 | import { ViewTransitions } from "next-view-transitions"; 6 | import { Toaster } from "sonner"; 7 | import { ModeToggle } from "@/components/mode-toggle"; 8 | import { CustomToaster } from "@/components/custom-toaster"; 9 | import { createMetadata } from "@/lib/metadata"; 10 | import { APP_NAME } from "@/constants"; 11 | import { PostHogProvider } from "@/providers/posthog-provider"; 12 | import NextTopLoader from "nextjs-toploader"; 13 | import { CookieConsent } from "@/components/cookie-consent"; 14 | 15 | export const metadata = createMetadata({ 16 | title: { 17 | template: `%s | ${APP_NAME}`, 18 | default: APP_NAME, 19 | }, 20 | description: "The easiest way to get started with your next project", 21 | }); 22 | 23 | export default function RootLayout({ 24 | children, 25 | }: Readonly<{ 26 | children: React.ReactNode; 27 | }>) { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 40 | 44 | {children} 45 | {/* */} 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } -------------------------------------------------------------------------------- /components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | import { usePathname } from "next/navigation"; 15 | import { useIsMobile } from "@/hooks/use-mobile"; 16 | import { cn } from "@/lib/utils"; 17 | 18 | export function ModeToggle({ 19 | className, 20 | caller, 21 | }: { 22 | className?: string; 23 | caller: "layout" | "page"; 24 | }) { 25 | const { setTheme } = useTheme(); 26 | const isMobile = useIsMobile(); 27 | const pathName = usePathname(); 28 | const disabledPathnamesStart: string[] = ["/app/settings"]; 29 | 30 | if ( 31 | disabledPathnamesStart.some((pathnameStart) => 32 | pathName.startsWith(pathnameStart), 33 | ) 34 | ) { 35 | return null; 36 | } else if (caller !== "page" && pathName === "/") { 37 | return null; 38 | } 39 | 40 | return ( 41 | 42 | 43 | 50 | 51 | 52 | setTheme("light")}> 53 | Light 54 | 55 | setTheme("dark")}> 56 | Dark 57 | 58 | setTheme("system")}> 59 | System 60 | 61 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /app/api/create-customer/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/create-customer/route.ts 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | import DodoPayments from 'dodopayments'; 4 | 5 | export async function POST(req: NextRequest) { 6 | try { 7 | // Check for API key 8 | if (!process.env.DODO_API_KEY) { 9 | console.error('DODO_API_KEY is missing'); 10 | return NextResponse.json({ success: false, error: 'API key missing' }, { status: 500 }); 11 | } 12 | 13 | // Initialize Dodo Payments client 14 | const client = new DodoPayments({ 15 | bearerToken: process.env.DODO_API_KEY, 16 | environment: process.env.DODO_ENVIRONMENT as 'live_mode' | 'test_mode' | undefined, 17 | }); 18 | 19 | // Extract customer data from the request 20 | const { customerId, email, firstName, lastName, phone } = await req.json(); 21 | 22 | // Validate required fields 23 | if (!customerId || !email) { 24 | return NextResponse.json({ success: false, error: 'Missing customerId or email' }, { status: 400 }); 25 | } 26 | 27 | // Create the customer 28 | const customer = await client.customers.create({ 29 | email: email, 30 | customer_id: customerId, 31 | firstName: firstName, 32 | lastName: lastName, 33 | phone: phone 34 | } as any); 35 | 36 | // Return success response 37 | return NextResponse.json({ success: true, customerId: customer.customer_id }); 38 | 39 | } catch (error: any) { 40 | console.error('Customer creation error:', error); 41 | 42 | // Handle different error types 43 | let statusCode = 500; 44 | let errorMessage = 'Failed to create customer'; 45 | 46 | if (error instanceof Error) { 47 | errorMessage = error.message; 48 | 49 | if (errorMessage.includes('409')) { // Conflict (customer ID already exists) 50 | statusCode = 409; 51 | errorMessage = 'Customer ID already exists'; 52 | } 53 | } 54 | 55 | return NextResponse.json({ success: false, error: errorMessage }, { status: statusCode }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Tabs = TabsPrimitive.Root; 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | TabsList.displayName = TabsPrimitive.List.displayName; 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )); 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )); 53 | TabsContent.displayName = TabsPrimitive.Content.displayName; 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 56 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-full px-3 text-xs", 26 | lg: "h-10 rounded-full px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /env.js: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | const env = createEnv({ 5 | // Server-side environment variables 6 | server: { 7 | // Database URLs 8 | DATABASE_URL_DEVELOPMENT: z.string(), 9 | DATABASE_URL_PRODUCTION: z.string(), 10 | 11 | // Authentication credentials 12 | GOOGLE_CLIENT_ID: z.string().optional(), 13 | GOOGLE_CLIENT_SECRET: z.string().optional(), 14 | GITHUB_CLIENT_ID: z.string().optional(), 15 | GITHUB_CLIENT_SECRET: z.string().optional(), 16 | BETTER_AUTH_SECRET: z.string(), 17 | 18 | // API keys 19 | RESEND_API_KEY: z.string(), 20 | 21 | // Payments 22 | DODO_API_KEY: z.string(), 23 | }, 24 | 25 | // Client-side public environment variables 26 | client: { 27 | // Application settings 28 | NEXT_PUBLIC_APP_URL: z.string(), 29 | NEXT_PUBLIC_CACHE_ENCRYPTION_KEY: z.string(), 30 | 31 | // Analytics 32 | NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(), 33 | NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), 34 | }, 35 | 36 | // Linking runtime environment variables 37 | runtimeEnv: { 38 | // Database URLs 39 | DATABASE_URL_DEVELOPMENT: process.env.DATABASE_URL_DEVELOPMENT, 40 | DATABASE_URL_PRODUCTION: process.env.DATABASE_URL_PRODUCTION, 41 | 42 | // Authentication credentials 43 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 44 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, 45 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, 46 | GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, 47 | BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, 48 | 49 | // API keys 50 | RESEND_API_KEY: process.env.RESEND_API_KEY, 51 | 52 | // Application settings 53 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, 54 | NEXT_PUBLIC_CACHE_ENCRYPTION_KEY: 55 | process.env.NEXT_PUBLIC_CACHE_ENCRYPTION_KEY, 56 | 57 | // Analytics 58 | NEXT_PUBLIC_POSTHOG_API_KEY: process.env.POSTHOG_API_KEY, 59 | NEXT_PUBLIC_POSTHOG_HOST: process.env.POSTHOG_HOST, 60 | 61 | // Payments 62 | DODO_API_KEY: process.env.DODO_API_KEY, 63 | }, 64 | }); 65 | 66 | export default env; 67 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )); 42 | CardTitle.displayName = "CardTitle"; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )); 54 | CardDescription.displayName = "CardDescription"; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )); 62 | CardContent.displayName = "CardContent"; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = "CardFooter"; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /app/api/create-checkout-session/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/create-checkout-session/route.ts 2 | import { NextResponse } from "next/server"; 3 | import DodoPayments from "dodopayments"; 4 | 5 | // Initialize DODO client directly 6 | const dodo = new DodoPayments({ 7 | bearerToken: process.env.DODO_API_KEY, 8 | environment: process.env.NODE_ENV === "production" ? "live_mode" : "test_mode", 9 | }); 10 | 11 | export async function POST(request: Request) { 12 | try { 13 | const { productId, planName, successUrl, cancelUrl } = await request.json(); 14 | 15 | if (!productId || !successUrl || !cancelUrl) { 16 | return NextResponse.json( 17 | { error: "Missing required parameters" }, 18 | { status: 400 } 19 | ); 20 | } 21 | 22 | // Create a checkout session with DODO - using correct parameter names 23 | const session = await dodo.payments.create({ 24 | success_url: successUrl, 25 | cancel_url: cancelUrl, 26 | line_items: [ 27 | { 28 | product_id: productId, 29 | quantity: 1, 30 | }, 31 | ], 32 | metadata: { 33 | plan_name: planName, 34 | }, 35 | } as any); // Using type assertion to bypass TypeScript errors 36 | 37 | // Access the checkout URL property using type assertion to bypass TypeScript errors 38 | // The property might be checkoutUrl, url, or something else depending on the SDK 39 | const checkoutSession = session as any; 40 | 41 | // First, try to determine which property contains the checkout URL by logging 42 | console.log("Payment session response:", JSON.stringify(checkoutSession, null, 2)); 43 | 44 | // Try different possible property names that might contain the checkout URL 45 | const checkoutUrl = 46 | checkoutSession.checkoutUrl || 47 | checkoutSession.checkout_url || 48 | checkoutSession.url || 49 | checkoutSession.redirect_url; 50 | 51 | if (!checkoutUrl) { 52 | throw new Error("Could not find checkout URL in response"); 53 | } 54 | 55 | return NextResponse.json({ 56 | checkoutUrl: checkoutUrl, 57 | }); 58 | } catch (error) { 59 | console.error("Error creating checkout session:", error); 60 | return NextResponse.json( 61 | { error: "Failed to create checkout session" }, 62 | { status: 500 } 63 | ); 64 | } 65 | } -------------------------------------------------------------------------------- /components/cookie-consent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { X } from "lucide-react"; 5 | 6 | export function CookieConsent() { 7 | const [showConsent, setShowConsent] = useState(false); 8 | 9 | useEffect(() => { 10 | // Check if user has already consented 11 | const hasConsented = localStorage.getItem("cookieConsent"); 12 | if (!hasConsented) { 13 | setShowConsent(true); 14 | } 15 | }, []); 16 | 17 | const acceptCookies = () => { 18 | localStorage.setItem("cookieConsent", "accepted"); 19 | setShowConsent(false); 20 | }; 21 | 22 | const declineCookies = () => { 23 | localStorage.setItem("cookieConsent", "declined"); 24 | // Here you would implement logic to disable non-essential cookies 25 | setShowConsent(false); 26 | }; 27 | 28 | if (!showConsent) return null; 29 | 30 | return ( 31 |
32 |
33 |
34 |

We value your privacy

35 |

36 | We use cookies to enhance your browsing experience, serve personalized items or content, and analyze our traffic. By clicking "Accept All", you consent to our use of cookies. 37 |

38 |
39 |
40 | 46 | 52 | 59 |
60 |
61 |
62 | ); 63 | } -------------------------------------------------------------------------------- /app/_components/features.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "@/components/ui/badge"; 2 | import React from "react"; 3 | 4 | interface Feature { 5 | title: string; 6 | description: string; 7 | } 8 | 9 | const features: Feature[] = [ 10 | { 11 | title: "Better-Auth + Resend", 12 | description: 13 | "Secure authentication system with magic link functionality and GitHub OAuth integration. Includes email delivery via Resend and session management.", 14 | }, 15 | { 16 | title: "Next.js 15 + TypeScript", 17 | description: 18 | "Modern React framework with App Router and Server Components. Full TypeScript support with strict type checking and API route handlers.", 19 | }, 20 | { 21 | title: "Drizzle + PostgreSQL", 22 | description: 23 | "Type-safe ORM with PostgreSQL integration. Features schema migrations, prepared statements, and optimized query building support.", 24 | }, 25 | { 26 | title: "PostHog Analytics", 27 | description: 28 | "Complete analytics integration with event tracking and feature flags. Includes user segmentation tools and dashboard configuration.", 29 | }, 30 | { 31 | title: "shadcn/ui + Tailwind", 32 | description: 33 | "Accessible component library built on Radix UI primitives. Features responsive styling with Tailwind CSS and built-in theme support.", 34 | }, 35 | { 36 | title: "Zod + React Hook Form", 37 | description: 38 | "Robust form validation with type-safe schemas and custom hooks. Includes client and server-side validation with seamless integration.", 39 | }, 40 | ]; 41 | 42 | const FeatureCard = ({ title, description }: Feature) => { 43 | return ( 44 |
45 | 49 | {title} 50 | 51 |

52 | {description} 53 |

54 |
55 | ); 56 | }; 57 | 58 | export function Features() { 59 | return ( 60 |
61 |
62 | {features.map((feature, index) => ( 63 | 64 | ))} 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### ⚠️ warning 2 | > **this project has been paused, is no longer active, and will not be maintained in the future.** 3 | > **do not use in production. no updates or support will be provided.** 4 | 5 | > i have since moved away from [next.js](https://nextjs.org) as the base building block for my applications and even for my company. 6 | > for marketing/public websites where seo and ssr is crucial, i recommend [astro](https://astro.build) with some sort of [cms](https://jamstack.org/headless-cms/). 7 | > for any webapps i recommend using [vite](https://vitejs.dev) + [react](https://react.dev) with [tanstack router](https://tanstack.com/router) and a [convex](https://convex.dev) backend, or [hono](https://hono.dev) with the [hono rpc client](https://hono.dev/docs/guides/rpc). 8 | 9 | 10 | ## a full-stack production-ready saas starter kit 11 | 12 | ![screenshot](public/images/landing_ss.png) 13 | 14 | ### features 15 | 16 | - magic link auth: login with better-auth & resend, plus github oauth 17 | - protected access: protected routes and middleware 18 | - modern ui/ux: tailwind css, dark/light mode, and dashboards 19 | - type-safe development: typescript, drizzle orm, postgresql, zod validation 20 | - analytics: posthog integration 21 | - payments (upcoming): stripe, dodopayments, and billing options 22 | 23 | ### tech stack 24 | 25 | - framework: next.js 26 | - authentication: better-auth 27 | - database: postgresql 28 | - orm: drizzle 29 | - styling: tailwind css 30 | - email: resend 31 | - analytics: posthog 32 | - validation: zod 33 | 34 | ### roadmap 35 | 36 | #### completed 37 | 38 | - [x] magic link authentication with better-auth and resend 39 | - [x] github oauth integration 40 | - [x] protected routes 41 | - [x] user settings 42 | - [x] dark/light mode 43 | - [x] dashboard & sidebar (shadcn) 44 | - [x] t3 env integration 45 | - [x] zod validation 46 | - [x] drizzle + postgresql setup 47 | - [x] posthog analytics 48 | - [x] landing page 49 | 50 | #### upcoming (cancelled) 51 | 52 | - [ ] payments (stripe and dodopayments integration) 53 | - [ ] minor fixes here and there to improve ux & dx 54 | - [ ] ship v1 55 | - [ ] create full setup tutorial 56 | 57 | ### contributing 58 | 59 | 1. fork the repository 60 | 2. create a feature branch (`git checkout -b feature/yourfeature`) 61 | 3. commit your changes (`git commit -m 'add yourfeature'`) 62 | 4. push to the branch (`git push origin feature/yourfeature`) 63 | 5. submit a pull request 64 | 65 | for issues or feature requests, create an issue in the github repository 66 | -------------------------------------------------------------------------------- /app/_components/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "@/components/ui/button"; 3 | import Link from "next/link"; 4 | import { GithubIcon, Twitter } from "lucide-react"; 5 | 6 | interface FooterLinkProps { 7 | href: string; 8 | title: string; 9 | } 10 | 11 | interface FooterSocialLinkProps { 12 | href: string; 13 | icon: React.ElementType; // Use React.ElementType for better typing 14 | } 15 | 16 | const FooterLink = ({ href, title }: FooterLinkProps) => ( 17 | 21 | {title} 22 | 23 | ); 24 | 25 | const FooterSocialLink = ({ href, icon: Icon }: FooterSocialLinkProps) => ( 26 | 35 | 36 | 37 | ); 38 | 39 | 40 | const footerSocialLinks: FooterSocialLinkProps[] = [ 41 | { 42 | href: "https://github.com/asendlabs/startstack", 43 | icon: GithubIcon, 44 | }, 45 | { 46 | href: "https://twitter.com/warisareshi", 47 | icon: Twitter, 48 | }, 49 | ]; 50 | 51 | // Uncomment when ready to use footer links 52 | // const footerLinks: FooterLinkProps[] = [ 53 | // { 54 | // href: "/policies/privacy", 55 | // title: "Privacy Policy", 56 | // }, 57 | // { 58 | // href: "/policies/terms", 59 | // title: "Terms and Conditions", 60 | // }, 61 | // { 62 | // href: "/policies/refund", 63 | // title: "Refund Policy", 64 | // }, 65 | // ]; 66 | 67 | export function Footer() { 68 | return ( 69 |
70 |
71 | © Startstack by Asend Labs | 2024 72 | {/* Uncomment when ready to use footer links */} 73 | {/* {footerLinks.map((link, index) => ( 74 | 75 | ))} */} 76 |
77 |
78 | {footerSocialLinks.map((link, index) => ( 79 | 80 | ))} 81 |
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /app/app/settings/_components/notifications-page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Switch } from "@/components/ui/switch"; 12 | import { Label } from "@/components/ui/label"; 13 | 14 | export function NotificationPage() { 15 | const [notifications, setNotifications] = useState({ 16 | essential: true, 17 | tipsAndEducation: false, 18 | newFeatures: true, 19 | }); 20 | 21 | const handleToggle = (type: keyof typeof notifications) => { 22 | setNotifications((prev) => ({ ...prev, [type]: !prev[type] })); 23 | // NOTIFICATION SETTINGS ARE NOT IMPLEMENTED YET IN THE BACKEND 24 | }; 25 | 26 | return ( 27 | 28 | 29 | Notifications 30 | Choose what emails you get from us 31 | 32 | 33 |
34 |
35 | 38 |

39 | Important account and security updates 40 |

41 |
42 | handleToggle("essential")} 46 | disabled 47 | /> 48 |
49 |
50 |
51 | 52 |

53 | Learn how to get the most out of our platform 54 |

55 |
56 | handleToggle("tipsAndEducation")} 60 | /> 61 |
62 |
63 |
64 | 65 |

66 | Be the first to know about new features and updates 67 |

68 |
69 | handleToggle("newFeatures")} 73 | /> 74 |
75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "startstack", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "drizzle:migrate:dev": "drizzle-kit migrate --config=database/config.dev.ts", 11 | "drizzle:generate:dev": "drizzle-kit generate --config=database/config.dev.ts", 12 | "drizzle:migrate:prod": "drizzle-kit migrate --config=database/config.prod.ts", 13 | "drizzle:generate:prod": "drizzle-kit generate --config=database/config.prod.ts", 14 | "prettier:check": "prettier --check --ignore-path .gitignore .", 15 | "prettier:format": "prettier --write --ignore-path .gitignore ." 16 | }, 17 | "dependencies": { 18 | "@better-fetch/fetch": "^1.1.12", 19 | "@hookform/resolvers": "^3.9.1", 20 | "@neondatabase/serverless": "^0.10.4", 21 | "@radix-ui/react-avatar": "^1.1.2", 22 | "@radix-ui/react-checkbox": "^1.1.3", 23 | "@radix-ui/react-collapsible": "^1.1.2", 24 | "@radix-ui/react-dialog": "^1.1.4", 25 | "@radix-ui/react-dropdown-menu": "^2.1.4", 26 | "@radix-ui/react-label": "^2.1.1", 27 | "@radix-ui/react-select": "^2.1.4", 28 | "@radix-ui/react-separator": "^1.1.1", 29 | "@radix-ui/react-slot": "^1.1.1", 30 | "@radix-ui/react-switch": "^1.1.2", 31 | "@radix-ui/react-tabs": "^1.1.2", 32 | "@radix-ui/react-toast": "^1.2.4", 33 | "@radix-ui/react-tooltip": "^1.1.6", 34 | "@react-email/components": "0.0.30", 35 | "@t3-oss/env-nextjs": "^0.11.1", 36 | "better-auth": "^1.1.1", 37 | "class-variance-authority": "^0.7.1", 38 | "clsx": "^2.1.1", 39 | "dodopayments": "^0.13.2", 40 | "dotenv": "^16.4.7", 41 | "drizzle-orm": "^0.37.0", 42 | "framer-motion": "12.0.0-alpha.1", 43 | "lucide-react": "^0.468.0", 44 | "next": "15.0.4", 45 | "next-safe-action": "^7.10.2", 46 | "next-themes": "^0.4.4", 47 | "next-view-transitions": "^0.3.4", 48 | "nextjs-toploader": "^3.7.15", 49 | "nuqs": "^2.2.3", 50 | "oslo": "^1.2.1", 51 | "postgres": "^3.4.5", 52 | "posthog-js": "^1.203.1", 53 | "react": "19.0.0", 54 | "react-dom": "19.0.0", 55 | "react-hook-form": "^7.54.2", 56 | "react-qr-code": "^2.0.15", 57 | "resend": "^4.0.1", 58 | "sonner": "^1.7.1", 59 | "tailwind-merge": "^2.5.5", 60 | "tailwindcss-animate": "^1.0.7", 61 | "ua-parser-js": "^2.0.0", 62 | "zod": "^3.24.1" 63 | }, 64 | "devDependencies": { 65 | "@types/node": "^22.10.2", 66 | "@types/react": "^19.0.2", 67 | "@types/react-dom": "^19.0.2", 68 | "drizzle-kit": "^0.29.1", 69 | "eslint": "^9.17.0", 70 | "eslint-config-next": "15.0.4", 71 | "eslint-config-prettier": "^9.1.0", 72 | "postcss": "^8.4.49", 73 | "prettier": "^3.4.2", 74 | "prettier-plugin-tailwindcss": "^0.6.9", 75 | "react-email": "3.0.3", 76 | "tailwindcss": "^3.4.17", 77 | "typescript": "^5.7.2" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { ChevronRight, MoreHorizontal } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode; 11 | } 12 | >(({ ...props }, ref) =>