├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── api │ ├── auth │ │ └── [kindeAuth] │ │ │ └── route.ts │ └── webhook │ │ └── stripe │ │ └── route.ts ├── components │ ├── DashboardNav.tsx │ ├── Navbar.tsx │ ├── Submitbuttons.tsx │ ├── Themetoggle.tsx │ ├── UserNav.tsx │ └── theme-provider.tsx ├── dashboard │ ├── billing │ │ └── page.tsx │ ├── layout.tsx │ ├── new │ │ ├── [id] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── page.tsx │ └── settings │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── lib │ ├── db.ts │ └── stripe.ts ├── page.tsx └── payment │ ├── cancelled │ └── page.tsx │ └── success │ └── page.tsx ├── components.json ├── components └── ui │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ └── textarea.tsx ├── lib └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── next.svg └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🚀 Build a SaaS Application using Next.js 14, Stripe, Kinde, Prisma, Supabase, and Tailwind! Learn step-by-step and elevate your development skills. 2 | 3 | - 🚀 Kinde Auth: https://dub.sh/xeU8r3v 4 | 5 | 6 | - 👨🏻‍💻 GitHub Repository: https://www.janmarshal.com/courses/build-a-next-js-14-blog-or-react-sanity-io-tailwind-css-shadcn-ui 7 | - 🌍 My Website: https://www.janmarshal.com 8 | - 📧 Business ONLY: jan@alenix.de 9 | 10 | Resources used: 11 | - Next.js: https://nextjs.org 12 | - Kinde: https://dub.sh/xeU8r3v 13 | - Tailwind.css: https://tailwindcss.com 14 | - Shadcn/UI: https://ui.shadcn.com 15 | - Stripe: https://stripe.com 16 | - Prisma: https://prisma.io 17 | - Supabase: https://supabase.com 18 | 19 | Features: 20 | 21 | - 🌐 nextjs App Router 22 | - 🔐 Kinde Authentication 23 | - 📧 Passwordless Auth 24 | - 🔑 OAuth (Google and GitHub) 25 | - 💿 supabase Database 26 | - 💨 prisma Orm 27 | - 🎨 Styling with tailwindcss and shadcn UI 28 | - ✅ Change the color scheme to your liking 29 | - 💵 stripe for subscription handling 30 | - 🪝 Implementation of Stripe Webhooks 31 | - 😶‍🌫️ Deployment to vercel 32 | 33 | - Pending States 34 | - Cache Revalidation 35 | - Stripe Customer Portal 36 | - Stripe Checkout page 37 | - Server side implementation 38 | - Add Notes, View Notes, Edit Notes, Delete Nodes 39 | 40 | 41 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 42 | 43 | ## Getting Started 44 | 45 | First, run the development server: 46 | 47 | ```bash 48 | npm run dev 49 | # or 50 | yarn dev 51 | # or 52 | pnpm dev 53 | # or 54 | bun dev 55 | ``` 56 | 57 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 58 | 59 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 60 | 61 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 62 | 63 | ## Learn More 64 | 65 | To learn more about Next.js, take a look at the following resources: 66 | 67 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 68 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 69 | 70 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 71 | 72 | ## Deploy on Vercel 73 | 74 | 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. 75 | 76 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 77 | -------------------------------------------------------------------------------- /app/api/auth/[kindeAuth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handleAuth } from "@kinde-oss/kinde-auth-nextjs/server"; 2 | 3 | export const GET = handleAuth(); 4 | -------------------------------------------------------------------------------- /app/api/webhook/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { stripe } from "@/app/lib/stripe"; 2 | import { headers } from "next/headers"; 3 | import Stripe from "stripe"; 4 | import prisma from "@/app/lib/db"; 5 | 6 | export async function POST(req: Request) { 7 | const body = await req.text(); 8 | 9 | const signature = headers().get("Stripe-Signature") as string; 10 | 11 | let event: Stripe.Event; 12 | 13 | try { 14 | event = stripe.webhooks.constructEvent( 15 | body, 16 | signature, 17 | process.env.STRIPE_WEBHOOK_SECRET as string 18 | ); 19 | } catch (error: unknown) { 20 | return new Response("webhook error", { status: 400 }); 21 | } 22 | 23 | const session = event.data.object as Stripe.Checkout.Session; 24 | 25 | if (event.type === "checkout.session.completed") { 26 | const subscription = await stripe.subscriptions.retrieve( 27 | session.subscription as string 28 | ); 29 | const customerId = String(session.customer); 30 | 31 | const user = await prisma.user.findUnique({ 32 | where: { 33 | stripeCustomerId: customerId, 34 | }, 35 | }); 36 | 37 | if (!user) throw new Error("User not found..."); 38 | 39 | await prisma.subscription.create({ 40 | data: { 41 | stripeSubscriptionId: subscription.id, 42 | userId: user.id, 43 | currentPeriodStart: subscription.current_period_start, 44 | currentPeriodEnd: subscription.current_period_end, 45 | status: subscription.status, 46 | planId: subscription.items.data[0].plan.id, 47 | invterval: String(subscription.items.data[0].plan.interval), 48 | }, 49 | }); 50 | } 51 | 52 | if (event.type === "invoice.payment_succeeded") { 53 | const subscription = await stripe.subscriptions.retrieve( 54 | session.subscription as string 55 | ); 56 | 57 | await prisma.subscription.update({ 58 | where: { 59 | stripeSubscriptionId: subscription.id, 60 | }, 61 | data: { 62 | planId: subscription.items.data[0].price.id, 63 | currentPeriodStart: subscription.current_period_start, 64 | currentPeriodEnd: subscription.current_period_end, 65 | status: subscription.status, 66 | }, 67 | }); 68 | } 69 | 70 | return new Response(null, { status: 200 }); 71 | } 72 | -------------------------------------------------------------------------------- /app/components/DashboardNav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { CreditCard, Home, Settings } from "lucide-react"; 5 | import Link from "next/link"; 6 | import { usePathname } from "next/navigation"; 7 | import { navItems } from "./UserNav"; 8 | 9 | export function DashboardNav() { 10 | const pathname = usePathname(); 11 | console.log(pathname); 12 | return ( 13 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { ThemeToggle } from "./Themetoggle"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | RegisterLink, 6 | LoginLink, 7 | } from "@kinde-oss/kinde-auth-nextjs/components"; 8 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 9 | import { UserNav } from "./UserNav"; 10 | 11 | export async function Navbar() { 12 | const { isAuthenticated, getUser } = getKindeServerSession(); 13 | const user = await getUser(); 14 | 15 | return ( 16 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/components/Submitbuttons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Loader2, Trash } from "lucide-react"; 5 | import { useFormStatus } from "react-dom"; 6 | 7 | export function SubmitButton() { 8 | const { pending } = useFormStatus(); 9 | return ( 10 | <> 11 | {pending ? ( 12 | 15 | ) : ( 16 | 19 | )} 20 | 21 | ); 22 | } 23 | 24 | export function StripeSubscriptionCreationButton() { 25 | const { pending } = useFormStatus(); 26 | 27 | return ( 28 | <> 29 | {pending ? ( 30 | 33 | ) : ( 34 | 37 | )} 38 | 39 | ); 40 | } 41 | 42 | export function StripePortal() { 43 | const { pending } = useFormStatus(); 44 | 45 | return ( 46 | <> 47 | {pending ? ( 48 | 51 | ) : ( 52 | 55 | )} 56 | 57 | ); 58 | } 59 | 60 | export function TrashDelete() { 61 | const { pending } = useFormStatus(); 62 | 63 | return ( 64 | <> 65 | {pending ? ( 66 | 69 | ) : ( 70 | 73 | )} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/components/Themetoggle.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 | 15 | export function ThemeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/components/UserNav.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuGroup, 7 | DropdownMenuItem, 8 | DropdownMenuLabel, 9 | DropdownMenuSeparator, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { CreditCard, DoorClosed, Home, Settings } from "lucide-react"; 13 | import { LogoutLink } from "@kinde-oss/kinde-auth-nextjs/components"; 14 | 15 | import Link from "next/link"; 16 | 17 | export const navItems = [ 18 | { name: "Home", href: "/dashboard", icon: Home }, 19 | { name: "Settings", href: "/dashboard/settings", icon: Settings }, 20 | { name: "Billing", href: "/dashboard/billing", icon: CreditCard }, 21 | ]; 22 | 23 | export function UserNav({ 24 | name, 25 | email, 26 | image, 27 | }: { 28 | name: string; 29 | email: string; 30 | image: string; 31 | }) { 32 | return ( 33 | 34 | 35 | 41 | 42 | 43 | 44 |
45 |

{name}

46 |

47 | {email} 48 |

49 |
50 |
51 | 52 | 53 | {navItems.map((item, index) => ( 54 | 55 | 59 | {item.name} 60 | 61 | 62 | 63 | 64 | 65 | ))} 66 | 67 | 68 | 69 | 73 | 74 | Logout{" "} 75 | 76 | 77 | 78 | 79 | 80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /app/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /app/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card"; 9 | import { CheckCircle2 } from "lucide-react"; 10 | import prisma from "@/app/lib/db"; 11 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 12 | import { getStripeSession, stripe } from "@/app/lib/stripe"; 13 | import { redirect } from "next/navigation"; 14 | import { 15 | StripePortal, 16 | StripeSubscriptionCreationButton, 17 | } from "@/app/components/Submitbuttons"; 18 | import { unstable_noStore as noStore } from "next/cache"; 19 | 20 | const featureItems = [ 21 | { name: "Lorem Ipsum something" }, 22 | { name: "Lorem Ipsum something" }, 23 | { name: "Lorem Ipsum something" }, 24 | { name: "Lorem Ipsum something" }, 25 | { name: "Lorem Ipsum something" }, 26 | ]; 27 | 28 | async function getData(userId: string) { 29 | noStore(); 30 | const data = await prisma.subscription.findUnique({ 31 | where: { 32 | userId: userId, 33 | }, 34 | select: { 35 | status: true, 36 | user: { 37 | select: { 38 | stripeCustomerId: true, 39 | }, 40 | }, 41 | }, 42 | }); 43 | 44 | return data; 45 | } 46 | 47 | export default async function BillingPage() { 48 | const { getUser } = getKindeServerSession(); 49 | const user = await getUser(); 50 | const data = await getData(user?.id as string); 51 | 52 | async function createSubscription() { 53 | "use server"; 54 | 55 | const dbUser = await prisma.user.findUnique({ 56 | where: { 57 | id: user?.id, 58 | }, 59 | select: { 60 | stripeCustomerId: true, 61 | }, 62 | }); 63 | 64 | if (!dbUser?.stripeCustomerId) { 65 | throw new Error("Unable to get customer id"); 66 | } 67 | 68 | const subscriptionUrl = await getStripeSession({ 69 | customerId: dbUser.stripeCustomerId, 70 | domainUrl: 71 | process.env.NODE_ENV == "production" 72 | ? (process.env.PRODUCTION_URL as string) 73 | : "http://localhost:3000", 74 | priceId: process.env.STRIPE_PRICE_ID as string, 75 | }); 76 | 77 | return redirect(subscriptionUrl); 78 | } 79 | 80 | async function createCustomerPortal() { 81 | "use server"; 82 | const session = await stripe.billingPortal.sessions.create({ 83 | customer: data?.user.stripeCustomerId as string, 84 | return_url: 85 | process.env.NODE_ENV === "production" 86 | ? (process.env.PRODUCTION_URL as string) 87 | : "http://localhost:3000/dashboard", 88 | }); 89 | 90 | return redirect(session.url); 91 | } 92 | 93 | if (data?.status === "active") { 94 | return ( 95 |
96 |
97 |
98 |

Subscription

99 |

100 | Settings reagding your subscription 101 |

102 |
103 |
104 | 105 | 106 | 107 | Edit Subscription 108 | 109 | Click on the button below, this will give you the opportunity to 110 | change your payment details and view your statement at the same 111 | time. 112 | 113 | 114 | 115 |
116 | 117 | 118 |
119 |
120 |
121 | ); 122 | } 123 | 124 | return ( 125 |
126 | 127 | 128 |
129 |

130 | Monthly 131 |

132 |
133 | 134 |
135 | $30 /mo 136 |
137 |

138 | Write as many notes as you want for $30 a Month 139 |

140 |
141 |
142 |
    143 | {featureItems.map((item, index) => ( 144 |
  • 145 |
    146 | 147 |
    148 |

    {item.name}

    149 |
  • 150 | ))} 151 |
152 | 153 |
154 | 155 | 156 |
157 |
158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { DashboardNav } from "../components/DashboardNav"; 3 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 4 | import { redirect } from "next/navigation"; 5 | import prisma from "../lib/db"; 6 | import { stripe } from "../lib/stripe"; 7 | import { unstable_noStore as noStore } from "next/cache"; 8 | 9 | async function getData({ 10 | email, 11 | id, 12 | firstName, 13 | lastName, 14 | profileImage, 15 | }: { 16 | email: string; 17 | id: string; 18 | firstName: string | undefined | null; 19 | lastName: string | undefined | null; 20 | profileImage: string | undefined | null; 21 | }) { 22 | noStore(); 23 | const user = await prisma.user.findUnique({ 24 | where: { 25 | id: id, 26 | }, 27 | select: { 28 | id: true, 29 | stripeCustomerId: true, 30 | }, 31 | }); 32 | 33 | if (!user) { 34 | const name = `${firstName ?? ""} ${lastName ?? ""}`; 35 | await prisma.user.create({ 36 | data: { 37 | id: id, 38 | email: email, 39 | name: name, 40 | }, 41 | }); 42 | } 43 | 44 | if (!user?.stripeCustomerId) { 45 | const data = await stripe.customers.create({ 46 | email: email, 47 | }); 48 | 49 | await prisma.user.update({ 50 | where: { 51 | id: id, 52 | }, 53 | data: { 54 | stripeCustomerId: data.id, 55 | }, 56 | }); 57 | } 58 | } 59 | 60 | export default async function DashboardLayout({ 61 | children, 62 | }: { 63 | children: ReactNode; 64 | }) { 65 | const { getUser } = getKindeServerSession(); 66 | const user = await getUser(); 67 | if (!user) { 68 | return redirect("/"); 69 | } 70 | await getData({ 71 | email: user.email as string, 72 | firstName: user.given_name as string, 73 | id: user.id as string, 74 | lastName: user.family_name as string, 75 | profileImage: user.picture, 76 | }); 77 | 78 | return ( 79 |
80 |
81 | 84 |
{children}
85 |
86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /app/dashboard/new/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SubmitButton } from "@/app/components/Submitbuttons"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardFooter, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Label } from "@/components/ui/label"; 13 | import { Textarea } from "@/components/ui/textarea"; 14 | import Link from "next/link"; 15 | import prisma from "@/app/lib/db"; 16 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 17 | import { redirect } from "next/navigation"; 18 | import { revalidatePath, unstable_noStore as noStore } from "next/cache"; 19 | 20 | async function getData({ userId, noteId }: { userId: string; noteId: string }) { 21 | noStore(); 22 | const data = await prisma.note.findUnique({ 23 | where: { 24 | id: noteId, 25 | userId: userId, 26 | }, 27 | select: { 28 | title: true, 29 | description: true, 30 | id: true, 31 | }, 32 | }); 33 | 34 | return data; 35 | } 36 | 37 | export default async function DynamicRoute({ 38 | params, 39 | }: { 40 | params: { id: string }; 41 | }) { 42 | const { getUser } = getKindeServerSession(); 43 | const user = await getUser(); 44 | const data = await getData({ userId: user?.id as string, noteId: params.id }); 45 | 46 | async function postData(formData: FormData) { 47 | "use server"; 48 | 49 | if (!user) throw new Error("you are not allowed"); 50 | 51 | const title = formData.get("title") as string; 52 | const description = formData.get("description") as string; 53 | 54 | await prisma.note.update({ 55 | where: { 56 | id: data?.id, 57 | userId: user.id, 58 | }, 59 | data: { 60 | description: description, 61 | title: title, 62 | }, 63 | }); 64 | 65 | revalidatePath("/dashboard"); 66 | 67 | return redirect("/dashboard"); 68 | } 69 | return ( 70 | 71 |
72 | 73 | Edit Note 74 | 75 | Right here you can now edit your notes 76 | 77 | 78 | 79 |
80 | 81 | 88 |
89 | 90 |
91 | 92 |