├── .env.example ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierrc ├── README.md ├── app ├── auth │ ├── actions.ts │ ├── auth │ │ ├── confirm │ │ │ └── route.ts │ │ └── logout │ │ │ └── route.ts │ └── callback │ │ └── route.ts ├── dashboard │ ├── actions.ts │ ├── layout.tsx │ └── page.tsx ├── favicon.ico ├── forgot-password │ ├── page.tsx │ ├── reset │ │ ├── page.tsx │ │ └── success │ │ │ └── page.tsx │ └── success │ │ └── page.tsx ├── globals.css ├── layout.tsx ├── login │ └── page.tsx ├── page.tsx ├── signup │ └── page.tsx ├── subscribe │ ├── page.tsx │ └── success │ │ └── page.tsx └── webhook │ └── stripe │ └── route.ts ├── components.json ├── components ├── DashboardHeader.tsx ├── DashboardHeaderProfileDropdown.tsx ├── ForgotPasswordForm.tsx ├── LoginForm.tsx ├── ProviderSigninBlock.tsx ├── ResetPasswordForm.tsx ├── SignupForm.tsx ├── StripePricingTable.tsx └── ui │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ └── skeleton.tsx ├── drizzle.config.ts ├── lib └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── hero.png ├── logo.png ├── next.svg └── vercel.svg ├── stripeSetup.ts ├── tailwind.config.ts ├── tsconfig.json └── utils ├── db ├── db.ts ├── migrations │ ├── 0000_shallow_fantastic_four.sql │ ├── 0001_rich_ricochet.sql │ ├── 0002_regular_wong.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ ├── 0001_snapshot.json │ │ └── _journal.json └── schema.ts ├── stripe └── api.ts └── supabase ├── client.ts ├── middleware.ts └── server.ts /.env.example: -------------------------------------------------------------------------------- 1 | # PLACE YOUR SUPABASE PROJECT URL AND ANON KEY HERE 2 | NEXT_PUBLIC_SUPABASE_URL=xxx 3 | NEXT_PUBLIC_SUPABASE_ANON_KEY=xxxx 4 | NEXT_PUBLIC_WEBSITE_URL=http://localhost:3000 5 | 6 | # PLACE YOUR POSTGRES DATABBASE UEL AND PASSWORD HERE 7 | DATABASE_URL=xxxxx 8 | 9 | # PLACE YOUR GOOGLE CLIENT ID AND SECRET HERE TO ENABLE OAUTH SOCIAL LOGIN (https://supabase.com/docs/guides/auth/social-login/auth-google) 10 | GOOGLE_OAUTH_CLIENT_ID=xxxx 11 | GOOGLE_OAUTH_CLIENT_SECRET=xxxx 12 | 13 | # Place GITHUB client id and secret to easily enable github oauth login 14 | GITHUB_OAUTH_CLIENT_ID=xxxxx 15 | GITHUB_OAUTH_CLIENT_SECRET=xxxxx 16 | # STRIPE PRICING TABLE PUBLIC KEY AND API 17 | STRIPE_PRICING_TABLE_ID=xxxxx 18 | STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx 19 | STRIPE_SECRET_KEY=sk_test_xxxx 20 | -------------------------------------------------------------------------------- /.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*.production 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.18.1 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "printWidth": 100, 8 | "bracketSpacing": true, 9 | "arrowParens": "always", 10 | "endOfLine": "lf", 11 | "plugins": ["prettier-plugin-tailwindcss"] 12 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | image 2 | 3 | image 4 | image 5 | image 6 | image 7 | 8 | 9 | 10 | 11 | 12 | This is the ultimate [Next.js](https://nextjs.org/) SAAS starter kit that includes a landing page, integrations with Supabase auth(Oauth, forget password, etc), PostgresDB with DrizzleORM and Stripe to collect payments, setup subscriptions and allow users to edit subscriptions/payment options. 13 | 14 | - Full sign up/ sign in/ logout/ forget password/ password reset flow 15 | - Oauth with Google and Github 16 | - Utilize Stripe Pricing Table and Stripe Checkout to setup customer billing 17 | - Integration with Stripe Customer Portal to allow users to manage billing settings 18 | - Protected routes under /dashboard 19 | - Drizzle ORM/Postgres integration 20 | - Tailwind CSS/shadcn 21 | - Stripe webhooks/ API hook to get customer current plan 22 | 23 | ## Getting Started 24 | 25 | As we will be setting up both dev and prod environments, simply use `.env.local` to develop locally and `.env` for production environments 26 | 27 | ### Setup Supabase 28 | 1. Create a new project on [Supabase](https://app.supabase.io/) 29 | 2. ADD `SUPABASE_URL` and `SUPABASE_ANON_KEY` to your .env file 30 | 3. 31 | ![image](https://github.com/user-attachments/assets/c8eb5236-96f1-4824-9998-3c54a4bcce12) 32 | 4. Add `NEXT_PUBLIC_WEBSITE_URL` to let Supabase know where to redirect the user after the Oauth flow(if using oauth). 33 | 34 | #### Setup Google OAUTH Social Auth 35 | You can easily set up social auth with this template. First navigate to google cloud and create a new project. All code is written. You just need to add the `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` to your `.env` file. 36 | 37 | 1. Follow these [instructions](https://supabase.com/docs/guides/auth/social-login/auth-google?queryGroups=environment&environment=server) to set up Google OAuth. 38 | 39 | #### Setup Github OAUTH Social Auth 40 | You can easily set up social auth with this template. First navigate to google cloud and create a new project. All code is written. You just need to add the `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET` to your `.env` file. 41 | 42 | 1. Follow these [instructions](https://supabase.com/docs/guides/auth/social-login/auth-github?queryGroups=environment&environment=server) to set up Github OAuth. 43 | 44 | ### Setup Postgres DB 45 | You can use any Postgres db with this boilerplate code. Feel free to use [Vercel's Marketplace](https://vercel.com/marketplace) to browse through a collection of first-party services to add to your Vercel project. 46 | 47 | Add `DATABASE_URL` to `.env` file e.g `postgresql://${USER}:${PASSWORD}@xxxx.us-east-2.aws.neon.tech/saas-template?sslmode=require` 48 | ### Setup OAuth with Social Providers 49 | 50 | #### Setup redirect url 51 | 1. Go to Supabase dashboard 52 | 2. Go to Authentication > Url Configuration 53 | 3. Place production url into "Site URL". 54 | image 55 | 56 | 57 | 58 | ### Setup Stripe 59 | 60 | In order to collect payments and setup subscriptions for your users, we will be making use of [Stripe Checkout](https://stripe.com/payments/checkout) and [Stripe Pricing Tables](https://docs.stripe.com/payments/checkout/pricing-table) and [Stripe Webhooks](https://docs.stripe.com/webhooks) 61 | 62 | 1. [Register for Stripe](https://dashboard.stripe.com/register) 63 | 2. get your `STRIPE_SECRET_KEY` key and add it to `.env`. Stripe has both a Test and Production API key. Once you verify your business on Stripe, you will be able to get access to production mode in Stripe which would come with a production API key. But until then, we can use [Stripe's Test Mode](https://docs.stripe.com/test-mode) to build our app 64 | 65 | ![image](https://github.com/user-attachments/assets/01da4beb-ae1d-45df-9de8-ca5e2b2c3470) 66 | 67 | 4. Open up `stripeSetup.ts` and change your product information 68 | 5. run `node --env-file=.env stripeSetup.ts` to setup your Stripe product 69 | 6. [Create a new Pricing Table](https://dashboard.stripe.com/test/pricing-tables) and add your newly created products 70 | 7. When creating your new Pricing Table, set the *Confirmation Page* to *Don't show confirmation page*. Add [YOUR_PUBLIC_URL/subscribe/success](YOUR_PUBLIC_URL/subscribe/success) as the value(use [http://localhost:3000/subscribe/success](http://localhost:3000/subscribe/success) for local development). This will redirect the user to your main dashboard when they have completed their checkout. For prod, this will be your public url 71 | 72 | ![image](https://github.com/user-attachments/assets/af8e9dda-3297-4e04-baa0-de7eac2a1579) 73 | 74 | 75 | 9. Add `STRIPE_PUBLISHABLE_KEY` and `STRIPE_PRICING_TABLE_ID` to `.env` 76 | ![image](https://github.com/user-attachments/assets/3b1a53d3-d2d4-4523-9e0e-87b63d9108a8) 77 | 78 | Your pricing table should now be set up 79 | 80 | ### Setup Database 81 | This boilerplate uses Drizzle ORM to interact with a PostgresDb. 82 | 83 | Before we start, please ensure that you have `DATABASE_URL` set. 84 | 85 | To create the necessary tables to start, run `npm drizzle-kit migrate` 86 | 87 | #### To alter or add a table 88 | 1. navigate to `/utils/db/schema.ts` 89 | 2. Edit/add a table 90 | 3. run `npx drizzle-kit activate` to generate migration files 91 | 4. run `npm drizzle-kit migrate` to apple migration 92 | 93 | ```bash 94 | npm run dev 95 | # or 96 | yarn dev 97 | # or 98 | pnpm dev 99 | # or 100 | bun dev 101 | ``` 102 | 103 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 104 | 105 | ## Deploy on Vercel 106 | 107 | 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. 108 | 109 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 110 | -------------------------------------------------------------------------------- /app/auth/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | import { createClient } from '@/utils/supabase/server' 3 | import { redirect } from "next/navigation" 4 | import { revalidatePath } from 'next/cache' 5 | import { createStripeCustomer } from '@/utils/stripe/api' 6 | import { db } from '@/utils/db/db' 7 | import { usersTable } from '@/utils/db/schema' 8 | import { eq } from 'drizzle-orm' 9 | const PUBLIC_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ? process.env.NEXT_PUBLIC_WEBSITE_URL : "http://localhost:3000" 10 | export async function resetPassword(currentState: { message: string }, formData: FormData) { 11 | 12 | const supabase = createClient() 13 | const passwordData = { 14 | password: formData.get('password') as string, 15 | confirm_password: formData.get('confirm_password') as string, 16 | code: formData.get('code') as string 17 | } 18 | if (passwordData.password !== passwordData.confirm_password) { 19 | return { message: "Passwords do not match" } 20 | } 21 | 22 | const { data } = await supabase.auth.exchangeCodeForSession(passwordData.code) 23 | 24 | let { error } = await supabase.auth.updateUser({ 25 | password: passwordData.password 26 | 27 | }) 28 | if (error) { 29 | return { message: error.message } 30 | } 31 | redirect(`/forgot-password/reset/success`) 32 | } 33 | 34 | export async function forgotPassword(currentState: { message: string }, formData: FormData) { 35 | 36 | const supabase = createClient() 37 | const email = formData.get('email') as string 38 | const { data, error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: `${PUBLIC_URL}/forgot-password/reset` }) 39 | 40 | if (error) { 41 | return { message: error.message } 42 | } 43 | redirect(`/forgot-password/success`) 44 | 45 | } 46 | export async function signup(currentState: { message: string }, formData: FormData) { 47 | const supabase = createClient() 48 | 49 | const data = { 50 | email: formData.get('email') as string, 51 | password: formData.get('password') as string, 52 | name: formData.get('name') as string, 53 | } 54 | 55 | try { 56 | // Check if user exists in our database first 57 | const existingDBUser = await db.select().from(usersTable).where(eq(usersTable.email, data.email)) 58 | 59 | if (existingDBUser.length > 0) { 60 | return { message: "An account with this email already exists. Please login instead." } 61 | } 62 | 63 | const { data: signUpData, error: signUpError } = await supabase.auth.signUp({ 64 | email: data.email, 65 | password: data.password, 66 | options: { 67 | emailRedirectTo: `${PUBLIC_URL}/auth/callback`, 68 | data: { 69 | email_confirm: process.env.NODE_ENV !== 'production', 70 | full_name: data.name 71 | } 72 | } 73 | }) 74 | 75 | if (signUpError) { 76 | if (signUpError.message.includes("already registered")) { 77 | return { message: "An account with this email already exists. Please login instead." } 78 | } 79 | return { message: signUpError.message } 80 | } 81 | 82 | if (!signUpData?.user) { 83 | return { message: "Failed to create user" } 84 | } 85 | 86 | // create Stripe Customer Record using signup response data 87 | const stripeID = await createStripeCustomer(signUpData.user.id, signUpData.user.email!, data.name) 88 | 89 | // Create record in DB 90 | await db.insert(usersTable).values({ 91 | id: signUpData.user.id, 92 | name: data.name, 93 | email: signUpData.user.email!, 94 | stripe_id: stripeID, 95 | plan: 'none' 96 | }) 97 | 98 | revalidatePath('/', 'layout') 99 | redirect('/subscribe') 100 | } catch (error) { 101 | console.error('Error in signup:', error) 102 | return { message: "Failed to setup user account" } 103 | } 104 | } 105 | 106 | export async function loginUser(currentState: { message: string }, formData: FormData) { 107 | const supabase = createClient() 108 | 109 | const data = { 110 | email: formData.get('email') as string, 111 | password: formData.get('password') as string, 112 | } 113 | 114 | const { error } = await supabase.auth.signInWithPassword(data) 115 | 116 | if (error) { 117 | return { message: error.message } 118 | } 119 | 120 | revalidatePath('/', 'layout') 121 | redirect('/dashboard') 122 | } 123 | 124 | 125 | 126 | export async function logout() { 127 | const supabase = createClient() 128 | const { error } = await supabase.auth.signOut() 129 | redirect('/login') 130 | } 131 | 132 | export async function signInWithGoogle() { 133 | const supabase = createClient() 134 | const { data, error } = await supabase.auth.signInWithOAuth({ 135 | provider: 'google', 136 | options: { 137 | redirectTo: `${PUBLIC_URL}/auth/callback`, 138 | }, 139 | }) 140 | 141 | if (data.url) { 142 | redirect(data.url) // use the redirect API for your server framework 143 | } 144 | } 145 | 146 | 147 | export async function signInWithGithub() { 148 | const supabase = createClient() 149 | const { data, error } = await supabase.auth.signInWithOAuth({ 150 | provider: 'github', 151 | options: { 152 | redirectTo: `${PUBLIC_URL}/auth/callback`, 153 | }, 154 | }) 155 | 156 | if (data.url) { 157 | redirect(data.url) // use the redirect API for your server framework 158 | } 159 | 160 | } -------------------------------------------------------------------------------- /app/auth/auth/confirm/route.ts: -------------------------------------------------------------------------------- 1 | import { type EmailOtpType } from '@supabase/supabase-js' 2 | import { type NextRequest } from 'next/server' 3 | 4 | import { createClient } from '@/utils/supabase/server' 5 | import { redirect } from 'next/navigation' 6 | 7 | export async function GET(request: NextRequest) { 8 | const { searchParams } = new URL(request.url) 9 | const token_hash = searchParams.get('token_hash') 10 | const type = searchParams.get('type') as EmailOtpType | null 11 | const next = searchParams.get('next') ?? '/' 12 | 13 | if (token_hash && type) { 14 | const supabase = createClient() 15 | 16 | const { error } = await supabase.auth.verifyOtp({ 17 | type, 18 | token_hash, 19 | }) 20 | if (!error) { 21 | // redirect user to specified redirect URL or root of app 22 | redirect(next) 23 | } 24 | } 25 | 26 | // redirect the user to an error page with some instructions 27 | redirect('/error') 28 | } -------------------------------------------------------------------------------- /app/auth/auth/logout/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@/utils/supabase/server' 2 | import { revalidatePath } from 'next/cache' 3 | import { type NextRequest, NextResponse } from 'next/server' 4 | 5 | export async function POST(req: NextRequest) { 6 | const supabase = createClient() 7 | 8 | // Check if a user's logged in 9 | const { 10 | data: { user }, 11 | } = await supabase.auth.getUser() 12 | 13 | if (user) { 14 | await supabase.auth.signOut() 15 | } 16 | 17 | revalidatePath('/', 'layout') 18 | return NextResponse.redirect(new URL('/login', req.url), { 19 | status: 302, 20 | }) 21 | } -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | // The client you created from the Server-Side Auth instructions 3 | import { createClient } from '@/utils/supabase/server' 4 | import { createStripeCustomer } from '@/utils/stripe/api' 5 | import { db } from '@/utils/db/db' 6 | import { usersTable } from '@/utils/db/schema' 7 | import { eq } from "drizzle-orm"; 8 | 9 | export async function GET(request: Request) { 10 | const { searchParams, origin } = new URL(request.url) 11 | const code = searchParams.get('code') 12 | // if "next" is in param, use it as the redirect URL 13 | const next = searchParams.get('next') ?? '/' 14 | 15 | if (code) { 16 | const supabase = createClient() 17 | const { error } = await supabase.auth.exchangeCodeForSession(code) 18 | if (!error) { 19 | const { 20 | data: { user }, 21 | } = await supabase.auth.getUser() 22 | 23 | // check to see if user already exists in db 24 | const checkUserInDB = await db.select().from(usersTable).where(eq(usersTable.email, user!.email!)) 25 | const isUserInDB = checkUserInDB.length > 0 ? true : false 26 | if (!isUserInDB) { 27 | // create Stripe customers 28 | const stripeID = await createStripeCustomer(user!.id, user!.email!, user!.user_metadata.full_name) 29 | // Create record in DB 30 | await db.insert(usersTable).values({ id: user!.id, name: user!.user_metadata.full_name, email: user!.email!, stripe_id: stripeID, plan: 'none' }) 31 | } 32 | 33 | const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer 34 | const isLocalEnv = process.env.NODE_ENV === 'development' 35 | if (isLocalEnv) { 36 | // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host 37 | return NextResponse.redirect(`${origin}${next}`) 38 | } else if (forwardedHost) { 39 | return NextResponse.redirect(`https://${forwardedHost}${next}`) 40 | } else { 41 | return NextResponse.redirect(`${origin}${next}`) 42 | } 43 | } 44 | } 45 | 46 | // return the user to an error page with instructions 47 | return NextResponse.redirect(`${origin}/auth/auth-code-error`) 48 | } -------------------------------------------------------------------------------- /app/dashboard/actions.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dzlau/stripe-supabase-saas-template/4d728ce774fd0668b84feef92bbfbe163c175c2b/app/dashboard/actions.ts -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import DashboardHeader from "@/components/DashboardHeader"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { createClient } from '@/utils/supabase/server' 5 | import { redirect } from "next/navigation" 6 | import { db } from '@/utils/db/db' 7 | import { usersTable } from '@/utils/db/schema' 8 | import { eq } from "drizzle-orm"; 9 | 10 | const inter = Inter({ subsets: ["latin"] }); 11 | 12 | export const metadata: Metadata = { 13 | title: "SAAS Starter Kit", 14 | description: "SAAS Starter Kit with Stripe, Supabase, Postgres", 15 | }; 16 | 17 | export default async function DashboardLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | // Check if user has plan selected. If not redirect to subscibe 23 | const supabase = createClient() 24 | 25 | const { 26 | data: { user }, 27 | } = await supabase.auth.getUser() 28 | 29 | // check user plan in db 30 | const checkUserInDB = await db.select().from(usersTable).where(eq(usersTable.email, user!.email!)) 31 | if (checkUserInDB[0].plan === "none") { 32 | console.log("User has no plan selected") 33 | return redirect('/subscribe') 34 | } 35 | 36 | 37 | return ( 38 | 39 | 40 | {children} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | import { createClient } from '@/utils/supabase/server' 4 | 5 | export default async function Dashboard() { 6 | const supabase = createClient() 7 | 8 | const { data, error } = await supabase.auth.getUser() 9 | if (error || !data?.user) { 10 | redirect('/login') 11 | } 12 | 13 | return ( 14 |
15 |
16 | Hello {data.user.email} 17 |
18 |
) 19 | 20 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dzlau/stripe-supabase-saas-template/4d728ce774fd0668b84feef92bbfbe163c175c2b/app/favicon.ico -------------------------------------------------------------------------------- /app/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Link from 'next/link' 3 | import Image from 'next/image' 4 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" 5 | import ForgotPasswordForm from '@/components/ForgotPasswordForm' 6 | export default function ForgotPassword() { 7 | return ( 8 |
9 | 10 | 11 |
12 | logo 13 |
14 | 15 | Forgot Your Password? 16 | Enter your email address 17 |
18 | 19 | 20 | 21 | 22 | 23 | Back to login 24 | 25 | 26 | Don't have an account? Signup 27 | 28 | 29 |
30 |
31 | ) 32 | } -------------------------------------------------------------------------------- /app/forgot-password/reset/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Link from 'next/link' 3 | import Image from 'next/image' 4 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" 5 | import ResetPasswordForm from '@/components/ResetPasswordForm' 6 | export default function ResetPassword() { 7 | return ( 8 |
9 | 10 | 11 |
12 | logo 13 |
14 | 15 | Enter your new Password 16 |
17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | ) 25 | } -------------------------------------------------------------------------------- /app/forgot-password/reset/success/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Link from 'next/link' 3 | import Image from 'next/image' 4 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" 5 | import ForgotPasswordForm from '@/components/ForgotPasswordForm' 6 | export default function ResetPasswordSuccess() { 7 | return ( 8 |
9 | 10 | 11 |
12 | 13 | logo 14 | 15 |
16 | 17 | Your password has been successfully reset! 18 | Login here 19 |
20 |
21 |
22 | ) 23 | } -------------------------------------------------------------------------------- /app/forgot-password/success/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Link from 'next/link' 3 | import Image from 'next/image' 4 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" 5 | import ForgotPasswordForm from '@/components/ForgotPasswordForm' 6 | export default function ForgotPasswordSuccess() { 7 | return ( 8 |
9 | 10 | 11 |
12 | 13 | logo 14 | 15 |
16 | 17 | Your password reset request has been processed. Check your email for a password reset request 18 | Go back to Login 19 |
20 |
21 |
22 | ) 23 | } -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 222.2 47.4% 11.2%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 222.2 84% 4.9%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 222.2 84% 4.9%; 36 | --foreground: 210 40% 98%; 37 | --card: 222.2 84% 4.9%; 38 | --card-foreground: 210 40% 98%; 39 | --popover: 222.2 84% 4.9%; 40 | --popover-foreground: 210 40% 98%; 41 | --primary: 210 40% 98%; 42 | --primary-foreground: 222.2 47.4% 11.2%; 43 | --secondary: 217.2 32.6% 17.5%; 44 | --secondary-foreground: 210 40% 98%; 45 | --muted: 217.2 32.6% 17.5%; 46 | --muted-foreground: 215 20.2% 65.1%; 47 | --accent: 217.2 32.6% 17.5%; 48 | --accent-foreground: 210 40% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 210 40% 98%; 51 | --border: 217.2 32.6% 17.5%; 52 | --input: 217.2 32.6% 17.5%; 53 | --ring: 212.7 26.8% 83.9%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | * { 64 | @apply border-border; 65 | } 66 | body { 67 | @apply bg-background text-foreground; 68 | } 69 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "SAAS Starter Kit", 9 | description: "SAAS Starter Kit with Stripe, Supabase, Postgres", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {/* Required for pricing table */} 20 | 21 | {children} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" 2 | 3 | import Link from 'next/link' 4 | import Image from 'next/image' 5 | 6 | import ProviderSigninBlock from '@/components/ProviderSigninBlock' 7 | import LoginForm from "@/components/LoginForm" 8 | export default function Login() { 9 | return ( 10 |
11 | 12 | 13 |
14 | 15 | logo 16 | 17 |
18 | 19 | Login 20 | Choose your preferred login method 21 |
22 | 23 | 24 |
25 |
26 | 27 |
28 |
29 | Or continue with 30 |
31 |
32 | 33 |
34 | 35 | 36 | Forgot password? 37 | 38 | 39 | Don't have an account? Signup 40 | 41 | 42 |
43 |
44 | 45 | ) 46 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import Image from "next/image" 3 | import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card" 4 | import Link from "next/link" 5 | import { Star, Check, Coins, UserCheck, Database } from "lucide-react" 6 | import Stripe from 'stripe' 7 | 8 | // Types 9 | interface StripeProduct { 10 | id: string; 11 | name: string; 12 | description: string | null; 13 | features: string[]; 14 | price: Stripe.Price; 15 | } 16 | 17 | // This makes the page dynamic instead of static 18 | export const revalidate = 3600 // Revalidate every hour 19 | 20 | async function getStripeProducts(): Promise { 21 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 22 | apiVersion: '2024-06-20' 23 | }); 24 | 25 | const products = await stripe.products.list({ 26 | active: true, 27 | expand: ['data.default_price'] 28 | }); 29 | 30 | return products.data.map(product => ({ 31 | id: product.id, 32 | name: product.name, 33 | description: product.description, 34 | features: product.metadata?.features ? JSON.parse(product.metadata.features) : [], 35 | price: product.default_price as Stripe.Price 36 | })); 37 | } 38 | 39 | export default async function LandingPage() { 40 | const products = await getStripeProducts(); 41 | 42 | return ( 43 |
44 |
45 | 46 | logo 47 | Acme Inc 48 | 49 | 60 | 65 |
66 |
67 |
68 |
69 |
70 |
71 |

72 | Saas Template with Supabase, Stripe, Databases 73 |

74 |

75 | NextJS Boilerplate with everything required to build your next SAAS Product 76 |

77 |
78 |
79 | 80 | 81 |
82 |
83 |
84 | Hero 85 |
86 |
87 |
88 |
89 |
90 |

Our Features

91 |
92 |
93 |
94 | 95 |
96 |

Payments

97 |

Seamlesly integrate Stripe Billing to capture subscription payments - Webhooks and all

98 |
99 |
100 |
101 | 102 |
103 |

Auth

104 |

Utilize our preexisting Superbase integration to auth your users and secure your app

105 |
106 |
107 |
108 | 109 |
110 |

Database

111 |

Hook into any PostgresDB instance

112 |
113 |
114 |
115 |
116 |
117 |
118 |

What Our Customers Say

119 |
120 | 121 | 122 |
123 | {[...Array(5)].map((_, i) => ( 124 | 125 | ))} 126 |
127 |

"This product has revolutionized our workflow. Highly recommended!"

128 |

- Sarah J., CEO

129 |
130 |
131 | 132 | 133 |
134 | {[...Array(5)].map((_, i) => ( 135 | 136 | ))} 137 |
138 |

"Wow everything is already integrated! Less time configuring, more time building!."

139 |

- Mark T., CTO

140 |
141 |
142 | 143 | 144 |
145 | {[...Array(5)].map((_, i) => ( 146 | 147 | ))} 148 |
149 |

"We&aposve seen a 200% increase in productivity since implementing this solution."

150 |

- Emily R., Operations Manager

151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |

Pricing Plans

159 |

Choose the perfect plan for your needs

160 |
161 | {products.map((product) => ( 162 | 163 | 164 | {product.name} 165 | {product.description} 166 | 167 | 168 |

169 | {product.price.unit_amount 170 | ? `$${(product.price.unit_amount / 100).toFixed(2)}/${product.price.recurring?.interval}` 171 | : 'Custom'} 172 |

173 |
    174 | {product.features?.map((feature, index) => ( 175 |
  • 176 | 177 | {feature} 178 |
  • 179 | ))} 180 |
181 |
182 | 183 | 187 | 188 | 189 | 190 |
191 | ))} 192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |

Start Your Journey Today

200 |

201 | Join thousands of satisfied customers and take your business to the next level. 202 |

203 |
204 |
205 | 206 | 207 | 208 |
209 |
210 |
211 |
212 |
213 |
214 |

© 2024 Acme Inc. All rights reserved.

215 | 223 |
224 |
225 | ) 226 | } -------------------------------------------------------------------------------- /app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" 2 | import Link from 'next/link' 3 | import Image from 'next/image' 4 | import SignupForm from "@/components/SignupForm" 5 | import ProviderSigninBlock from "@/components/ProviderSigninBlock" 6 | 7 | export default function Signup() { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | 15 | logo 16 | 17 |
18 | 19 | Signup 20 | Create your account now! 21 |
22 | 23 | 24 |
25 |
26 | 27 |
28 |
29 | Or continue with 30 |
31 |
32 | 33 |
34 | 35 | 36 | Have an account? Login 37 | 38 | 39 |
40 |
41 | 42 | ) 43 | } -------------------------------------------------------------------------------- /app/subscribe/page.tsx: -------------------------------------------------------------------------------- 1 | import StripePricingTable from "@/components/StripePricingTable"; 2 | import Image from "next/image" 3 | import { createClient } from '@/utils/supabase/server' 4 | import { createStripeCheckoutSession } from "@/utils/stripe/api"; 5 | export default async function Subscribe() { 6 | const supabase = createClient() 7 | const { 8 | data: { user }, 9 | } = await supabase.auth.getUser() 10 | const checkoutSessionSecret = await createStripeCheckoutSession(user!.email!) 11 | 12 | return ( 13 |
14 |
15 | logo 16 | Acme Inc 17 |
18 |
19 |
20 |

Pricing

21 |

Choose the right plan for your team! Cancel anytime!

22 |
23 | 24 |
25 |
26 | ) 27 | } -------------------------------------------------------------------------------- /app/subscribe/success/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" 2 | 3 | import Link from 'next/link' 4 | import Image from 'next/image' 5 | 6 | import ProviderSigninBlock from '@/components/ProviderSigninBlock' 7 | import LoginForm from "@/components/LoginForm" 8 | import { Button } from "@/components/ui/button" 9 | export default function SubscribeSuccess() { 10 | return ( 11 |
12 | 13 | 14 |
15 | 16 | logo 17 | 18 |
19 | 20 | Success 21 | Thank you for subscribing! 22 |
23 | 24 | 25 | 30 | 31 |
32 |
33 | 34 | ) 35 | } -------------------------------------------------------------------------------- /app/webhook/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/utils/db/db' 2 | import { usersTable } from '@/utils/db/schema' 3 | import { eq } from "drizzle-orm"; 4 | export async function POST(request: Request) { 5 | console.log('Webhook received') 6 | try { 7 | const response = await request.json() 8 | console.log(response) 9 | // On subscribe, write to db 10 | console.log(response.data.object.customer) 11 | await db.update(usersTable).set({ plan: response.data.object.id }).where(eq(usersTable.stripe_id, response.data.object.customer)); 12 | // Process the webhook payload 13 | } catch (error: any) { 14 | return new Response(`Webhook error: ${error.message}`, { 15 | status: 400, 16 | }) 17 | } 18 | return new Response('Success', { status: 200 }) 19 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/DashboardHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Bell, Menu, Search } from "lucide-react" 2 | import Link from "next/link" 3 | 4 | import { Button } from "@/components/ui/button" 5 | import { Input } from "@/components/ui/input" 6 | import Image from 'next/image' 7 | import { createClient } from '@/utils/supabase/server' 8 | import DashboardHeaderProfileDropdown from "./DashboardHeaderProfileDropdown" 9 | import { Badge } from "@/components/ui/badge" 10 | import { getStripePlan } from "@/utils/stripe/api" 11 | import { Suspense } from "react" 12 | import { Skeleton } from "@/components/ui/skeleton" 13 | 14 | export default async function DashboardHeader() { 15 | const supabase = createClient() 16 | const { data: { user }, error } = await supabase.auth.getUser() 17 | // Get the user's plan from Stripe 18 | const stripePlan = getStripePlan(user!.email!) 19 | 20 | return ( 21 |
22 |
23 |
24 | 25 | logo 26 | 27 | }> 28 | {stripePlan} 29 | 30 | 44 |
45 | 49 |
50 |
51 |
52 |
53 | 54 | 59 |
60 |
61 |
62 | 63 |
64 |
65 |
66 | ) 67 | } -------------------------------------------------------------------------------- /components/DashboardHeaderProfileDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuContent, 4 | DropdownMenuItem, 5 | DropdownMenuLabel, 6 | DropdownMenuSeparator, 7 | DropdownMenuTrigger, 8 | } from "@/components/ui/dropdown-menu" 9 | import { Bell, ReceiptText, User, Settings, HelpCircle, LogOut } from "lucide-react" 10 | import { Button } from "@/components/ui/button" 11 | import Link from "next/link" 12 | import { } from "@supabase/supabase-js" 13 | import { createClient } from '@/utils/supabase/server' 14 | import { logout } from '@/app/auth/actions' 15 | import { generateStripeBillingPortalLink } from "@/utils/stripe/api" 16 | 17 | export default async function DashboardHeaderProfileDropdown() { 18 | const supabase = createClient() 19 | const { data: { user }, error } = await supabase.auth.getUser() 20 | const billingPortalURL = await generateStripeBillingPortalLink(user!.email!) 21 | return ( 22 | 73 | ) 74 | } -------------------------------------------------------------------------------- /components/ForgotPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | 2 | "use client" 3 | import { Button } from "@/components/ui/button" 4 | import { Input } from "@/components/ui/input" 5 | import { Label } from "@/components/ui/label" 6 | import { useFormState } from 'react-dom' 7 | import { forgotPassword } from '@/app/auth/actions' 8 | export default function ForgotPasswordForm() { 9 | const initialState = { 10 | message: '' 11 | } 12 | const [formState, formAction] = useFormState(forgotPassword, initialState) 13 | return (<> 14 |
15 |
16 | 17 | 24 |
25 | 26 | {formState?.message && ( 27 |

{formState.message}

28 | )} 29 |
30 | ) 31 | } -------------------------------------------------------------------------------- /components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | 2 | "use client" 3 | import { Button } from "@/components/ui/button" 4 | import { Input } from "@/components/ui/input" 5 | import { Label } from "@/components/ui/label" 6 | import { useFormState } from 'react-dom' 7 | import { loginUser } from '@/app/auth/actions' 8 | export default function LoginForm() { 9 | const initialState = { 10 | message: '' 11 | } 12 | const [formState, formAction] = useFormState(loginUser, initialState) 13 | return (<> 14 |
15 |
16 | 17 | 24 |
25 |
26 | 27 | 33 |
34 | 35 | {formState?.message && ( 36 |

{formState.message}

37 | )} 38 |
39 | ) 40 | } -------------------------------------------------------------------------------- /components/ProviderSigninBlock.tsx: -------------------------------------------------------------------------------- 1 | import { FaGoogle, FaGithub } from "react-icons/fa"; 2 | import { signInWithGithub, signInWithGoogle } from '@/app/auth/actions' 3 | import { Button } from "@/components/ui/button" 4 | export default function ProviderSigninBlock() { 5 | const isGoogleEnabled = process.env.GOOGLE_OAUTH_CLIENT_ID ? true : false 6 | const isGithubEnabled = process.env.GITHUB_OAUTH_CLIENT_ID ? true : false 7 | return ( 8 | <> 9 |
10 | {isGoogleEnabled && ( 11 |
12 | 15 |
16 | )} 17 | {isGithubEnabled && ( 18 |
19 | 22 |
23 | )} 24 |
25 | 26 | ) 27 | } -------------------------------------------------------------------------------- /components/ResetPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | 2 | "use client" 3 | import { Button } from "@/components/ui/button" 4 | import { Input } from "@/components/ui/input" 5 | import { Label } from "@/components/ui/label" 6 | import { useFormState } from 'react-dom' 7 | import { resetPassword } from '@/app/auth/actions' 8 | import { useSearchParams } from "next/navigation"; 9 | import { Suspense } from "react" 10 | 11 | function GetCodeHiddenInput() { 12 | const searchParams = useSearchParams(); 13 | return 14 | } 15 | 16 | export default function ResetPasswordForm() { 17 | const initialState = { 18 | message: '' 19 | } 20 | const [formState, formAction] = useFormState(resetPassword, initialState) 21 | return (<> 22 |
23 |
24 | 25 | 32 | 39 | 40 | 41 | 42 |
43 | 44 | {formState?.message && ( 45 |

{formState.message}

46 | )} 47 |
48 | ) 49 | } -------------------------------------------------------------------------------- /components/SignupForm.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { Button } from "@/components/ui/button" 3 | import { Input } from "@/components/ui/input" 4 | import { Label } from "@/components/ui/label" 5 | import { useFormState, useFormStatus } from 'react-dom' 6 | import { signup } from '@/app/auth/actions' 7 | 8 | export default function SignupForm() { 9 | const initialState = { 10 | message: '' 11 | } 12 | 13 | const [formState, formAction] = useFormState(signup, initialState) 14 | const { pending } = useFormStatus() 15 | 16 | return ( 17 |
18 |
19 | 20 | 27 |
28 |
29 | 30 | 37 |
38 |
39 | 40 | 46 |
47 | 48 | {formState?.message && ( 49 |

{formState.message}

50 | )} 51 |
52 | ) 53 | } -------------------------------------------------------------------------------- /components/StripePricingTable.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { useState, useEffect } from 'react'; 3 | 4 | declare global { 5 | namespace JSX { 6 | interface IntrinsicElements { 7 | 'stripe-pricing-table': React.DetailedHTMLProps, HTMLElement>; 8 | } 9 | } 10 | } 11 | export default function StripePricingTable({ checkoutSessionSecret }: { checkoutSessionSecret: string }) { 12 | 13 | return ( 14 | 19 | 20 | ) 21 | 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /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-full 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 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 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/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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /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 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | import { config } from 'dotenv'; 3 | 4 | process.env.NODE_ENV !== 'production' ? config({ path: '.env' }) : config({ path: '.env.local' })// or .env.local 5 | config({ path: '.env.local' }) 6 | export default defineConfig({ 7 | schema: "./utils/db/schema.ts", 8 | out: "./utils/db/migrations", 9 | dialect: "postgresql", 10 | dbCredentials: { 11 | url: process.env.DATABASE_URL!, 12 | }, 13 | }); -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server' 2 | import { updateSession } from '@/utils/supabase/middleware' 3 | 4 | export async function middleware(request: NextRequest) { 5 | return await updateSession(request) 6 | } 7 | 8 | export const config = { 9 | matcher: [ 10 | /* 11 | * Match all request paths except for the ones starting with: 12 | * - _next/static (static files) 13 | * - _next/image (image optimization files) 14 | * - favicon.ico (favicon file) 15 | * Feel free to modify this pattern to include more paths. 16 | */ 17 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', 18 | ], 19 | } 20 | 21 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "npx drizzle-kit migrate && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "db:generate": "drizzle-kit generate", 11 | "db:migrate": "drizzle-kit migrate", 12 | "db:push": "drizzle-kit push", 13 | "db:studio": "drizzle-kit studio", 14 | "format": "prettier --write .", 15 | "format:check": "prettier --check .", 16 | "stripe:setup": "ts-node stripeSetup.ts" 17 | }, 18 | "dependencies": { 19 | "@radix-ui/react-dropdown-menu": "^2.1.1", 20 | "@radix-ui/react-label": "^2.1.0", 21 | "@radix-ui/react-slot": "^1.1.0", 22 | "@supabase/auth-ui-react": "^0.4.7", 23 | "@supabase/auth-ui-shared": "^0.1.8", 24 | "@supabase/ssr": "^0.5.0", 25 | "@supabase/supabase-js": "^2.45.1", 26 | "class-variance-authority": "^0.7.0", 27 | "clsx": "^2.1.1", 28 | "dotenv": "^16.4.5", 29 | "drizzle-kit": "^0.24.2", 30 | "drizzle-orm": "^0.33.0", 31 | "lucide-react": "^0.428.0", 32 | "next": "14.2.5", 33 | "postgres": "^3.4.4", 34 | "react": "^18", 35 | "react-dom": "^18", 36 | "react-icons": "^5.3.0", 37 | "stripe": "^16.9.0", 38 | "tailwind-merge": "^2.5.2", 39 | "tailwindcss-animate": "^1.0.7" 40 | }, 41 | "devDependencies": { 42 | "@types/node": "^20.17.8", 43 | "@types/react": "^18", 44 | "@types/react-dom": "^18", 45 | "dotenv": "^16.4.5", 46 | "eslint": "^8", 47 | "eslint-config-next": "14.2.5", 48 | "postcss": "^8", 49 | "tailwindcss": "^3.4.1", 50 | "typescript": "^5", 51 | "prettier": "^3.2.0", 52 | "prettier-plugin-tailwindcss": "^0.5.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dzlau/stripe-supabase-saas-template/4d728ce774fd0668b84feef92bbfbe163c175c2b/public/hero.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dzlau/stripe-supabase-saas-template/4d728ce774fd0668b84feef92bbfbe163c175c2b/public/logo.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stripeSetup.ts: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | require('dotenv').config(); // Load from .env in production 3 | } else { 4 | require('dotenv').config({ path: '.env.local' }); // Load from .env.local in development 5 | } 6 | 7 | const Stripe = require('stripe'); 8 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); 9 | 10 | // Types 11 | interface Plan { 12 | name: string; 13 | price: number; 14 | description: string; 15 | features: string[]; 16 | } 17 | 18 | interface StripeProduct { 19 | id: string; 20 | name: string; 21 | metadata?: { 22 | features?: string; 23 | }; 24 | } 25 | 26 | interface WebhookEndpoint { 27 | url: string; 28 | } 29 | 30 | // Configuration 31 | const PUBLIC_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "http://localhost:3000"; 32 | const CURRENCY = 'usd'; 33 | const IS_PRODUCTION = process.env.NODE_ENV === 'production'; 34 | 35 | // Product Plans 36 | const plans: Plan[] = [ 37 | { 38 | name: IS_PRODUCTION ? 'Basic' : 'Basic-Test', 39 | price: 1000, // price in cents 40 | description: 'Perfect for small teams and individuals', 41 | features: [ 42 | 'Up to 10 users', 43 | 'Up to 1000 records', 44 | 'Up to 1000 API calls' 45 | ] 46 | }, 47 | { 48 | name: IS_PRODUCTION ? 'Pro' : 'Pro-Test', 49 | price: 2000, 50 | description: 'Great for growing teams', 51 | features: [ 52 | 'Up to 100 users', 53 | 'Up to 10000 records', 54 | 'Up to 10000 API calls' 55 | ] 56 | }, 57 | { 58 | name: IS_PRODUCTION ? 'Enterprise' : 'Enterprise-Test', 59 | price: 5000, 60 | description: 'For large organizations', 61 | features: [ 62 | 'Unlimited users', 63 | 'Unlimited records', 64 | 'Unlimited API calls' 65 | ] 66 | } 67 | ]; 68 | 69 | // Helper Functions 70 | async function createProduct(plan: Plan): Promise { 71 | // Check if product exists 72 | const existingProducts = await stripe.products.list({ active: true }); 73 | let product = existingProducts.data.find((p: StripeProduct) => p.name === plan.name); 74 | 75 | if (!product) { 76 | // Create new product if it doesn't exist 77 | product = await stripe.products.create({ 78 | name: plan.name, 79 | description: plan.description, 80 | metadata: { 81 | features: JSON.stringify(plan.features) 82 | } 83 | }); 84 | console.log(`Created product: ${plan.name}`); 85 | } else { 86 | // Update existing product's features 87 | product = await stripe.products.update(product.id, { 88 | description: plan.description, 89 | metadata: { 90 | features: JSON.stringify(plan.features) 91 | } 92 | }); 93 | console.log(`Updated product: ${plan.name}`); 94 | } 95 | 96 | return product; 97 | } 98 | 99 | async function createPrice(product: StripeProduct, plan: Plan): Promise { 100 | // Check if price exists 101 | const existingPrices = await stripe.prices.list({ 102 | product: product.id, 103 | active: true 104 | }); 105 | 106 | if (existingPrices.data.length === 0) { 107 | // Create new price if none exists 108 | const price = await stripe.prices.create({ 109 | product: product.id, 110 | unit_amount: plan.price, 111 | currency: CURRENCY, 112 | recurring: { interval: 'month' } 113 | }); 114 | 115 | // Set as default price 116 | await stripe.products.update(product.id, { 117 | default_price: price.id 118 | }); 119 | console.log(`Created price for ${plan.name}: ${price.id}`); 120 | } 121 | } 122 | 123 | async function setupWebhook(): Promise { 124 | // Skip webhook setup in development 125 | if (!IS_PRODUCTION) { 126 | console.log('Skipping webhook setup in development'); 127 | console.log('Use Stripe CLI for local testing: https://stripe.com/docs/stripe-cli'); 128 | return; 129 | } 130 | 131 | const webhooks = await stripe.webhookEndpoints.list(); 132 | const webhookUrl = `${PUBLIC_URL}/webhook/stripe`; 133 | 134 | if (!webhooks.data.some((webhook: WebhookEndpoint) => webhook.url === webhookUrl)) { 135 | await stripe.webhookEndpoints.create({ 136 | enabled_events: [ 137 | 'customer.subscription.created', 138 | 'customer.subscription.deleted', 139 | 'customer.subscription.updated' 140 | ], 141 | url: webhookUrl, 142 | }); 143 | console.log('Created webhook endpoint'); 144 | } 145 | } 146 | 147 | // Main Setup Function 148 | async function setupStripe(): Promise { 149 | try { 150 | console.log(`Setting up Stripe in ${IS_PRODUCTION ? 'production' : 'development'} mode...`); 151 | 152 | // Setup products and prices 153 | for (const plan of plans) { 154 | const product = await createProduct(plan); 155 | await createPrice(product, plan); 156 | } 157 | 158 | // Setup webhook 159 | await setupWebhook(); 160 | 161 | console.log('Stripe setup completed successfully'); 162 | } catch (error) { 163 | console.error('Error setting up Stripe:', error); 164 | throw error; 165 | } 166 | } 167 | 168 | // Run Setup 169 | setupStripe().catch(console.error); -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /utils/db/db.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/postgres-js'; 2 | import postgres from 'postgres'; 3 | 4 | 5 | const client = postgres(process.env.DATABASE_URL!); 6 | export const db = drizzle(client); 7 | 8 | -------------------------------------------------------------------------------- /utils/db/migrations/0000_shallow_fantastic_four.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "users_table" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "name" text NOT NULL, 4 | "email" text NOT NULL, 5 | "plan" text NOT NULL, 6 | CONSTRAINT "users_table_email_unique" UNIQUE("email") 7 | ); 8 | -------------------------------------------------------------------------------- /utils/db/migrations/0001_rich_ricochet.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users_table" ADD COLUMN "stripe_id" text NOT NULL; -------------------------------------------------------------------------------- /utils/db/migrations/0002_regular_wong.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users_table" ALTER COLUMN "id" SET DATA TYPE text; -------------------------------------------------------------------------------- /utils/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "780d08c2-0f25-4e86-a340-8772ac6d355f", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.users_table": { 8 | "name": "users_table", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "email": { 24 | "name": "email", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "plan": { 30 | "name": "plan", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | } 35 | }, 36 | "indexes": {}, 37 | "foreignKeys": {}, 38 | "compositePrimaryKeys": {}, 39 | "uniqueConstraints": { 40 | "users_table_email_unique": { 41 | "name": "users_table_email_unique", 42 | "nullsNotDistinct": false, 43 | "columns": [ 44 | "email" 45 | ] 46 | } 47 | } 48 | } 49 | }, 50 | "enums": {}, 51 | "schemas": {}, 52 | "sequences": {}, 53 | "_meta": { 54 | "columns": {}, 55 | "schemas": {}, 56 | "tables": {} 57 | } 58 | } -------------------------------------------------------------------------------- /utils/db/migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "28b09584-c21b-4a7e-86a3-15b6426cdaa3", 3 | "prevId": "780d08c2-0f25-4e86-a340-8772ac6d355f", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.users_table": { 8 | "name": "users_table", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "email": { 24 | "name": "email", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "plan": { 30 | "name": "plan", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "stripe_id": { 36 | "name": "stripe_id", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": true 40 | } 41 | }, 42 | "indexes": {}, 43 | "foreignKeys": {}, 44 | "compositePrimaryKeys": {}, 45 | "uniqueConstraints": { 46 | "users_table_email_unique": { 47 | "name": "users_table_email_unique", 48 | "nullsNotDistinct": false, 49 | "columns": [ 50 | "email" 51 | ] 52 | } 53 | } 54 | } 55 | }, 56 | "enums": {}, 57 | "schemas": {}, 58 | "sequences": {}, 59 | "_meta": { 60 | "columns": {}, 61 | "schemas": {}, 62 | "tables": {} 63 | } 64 | } -------------------------------------------------------------------------------- /utils/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1725044409779, 9 | "tag": "0000_shallow_fantastic_four", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1725049000329, 16 | "tag": "0001_rich_ricochet", 17 | "breakpoints": true 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /utils/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; 2 | 3 | export const usersTable = pgTable('users_table', { 4 | id: text('id').primaryKey(), 5 | name: text('name').notNull(), 6 | email: text('email').notNull().unique(), 7 | plan: text('plan').notNull(), 8 | stripe_id: text('stripe_id').notNull(), 9 | }); 10 | 11 | export type InsertUser = typeof usersTable.$inferInsert; 12 | export type SelectUser = typeof usersTable.$inferSelect; 13 | -------------------------------------------------------------------------------- /utils/stripe/api.ts: -------------------------------------------------------------------------------- 1 | import { Stripe } from 'stripe'; 2 | import { db } from '../db/db'; 3 | import { usersTable } from '../db/schema'; 4 | import { eq } from "drizzle-orm"; 5 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) 6 | const PUBLIC_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ? process.env.NEXT_PUBLIC_WEBSITE_URL : "http://localhost:3000" 7 | export async function getStripePlan(email: string) { 8 | 9 | const user = await db.select().from(usersTable).where(eq(usersTable.email, email)) 10 | const subscription = await stripe.subscriptions.retrieve( 11 | user[0].plan 12 | ); 13 | const productId = subscription.items.data[0].plan.product as string 14 | const product = await stripe.products.retrieve(productId) 15 | return product.name 16 | } 17 | 18 | export async function createStripeCustomer(id: string, email: string, name?: string) { 19 | const customer = await stripe.customers.create({ 20 | name: name ? name : "", 21 | email: email, 22 | metadata: { 23 | supabase_id: id 24 | } 25 | }); 26 | // Create a new customer in Stripe 27 | return customer.id 28 | } 29 | 30 | export async function createStripeCheckoutSession(email: string) { 31 | const user = await db.select().from(usersTable).where(eq(usersTable.email, email)) 32 | const customerSession = await stripe.customerSessions.create({ 33 | customer: user[0].stripe_id, 34 | components: { 35 | pricing_table: { 36 | enabled: true, 37 | }, 38 | }, 39 | }); 40 | return customerSession.client_secret 41 | } 42 | 43 | export async function generateStripeBillingPortalLink(email: string) { 44 | const user = await db.select().from(usersTable).where(eq(usersTable.email, email)) 45 | const portalSession = await stripe.billingPortal.sessions.create({ 46 | customer: user[0].stripe_id, 47 | return_url: `${PUBLIC_URL}/dashboard`, 48 | }); 49 | return portalSession.url 50 | } -------------------------------------------------------------------------------- /utils/supabase/client.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from '@supabase/ssr' 2 | 3 | export function createClient() { 4 | return createBrowserClient( 5 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 6 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 7 | ) 8 | } -------------------------------------------------------------------------------- /utils/supabase/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient } from '@supabase/ssr' 2 | import { NextResponse, type NextRequest } from 'next/server' 3 | 4 | export async function updateSession(request: NextRequest) { 5 | let supabaseResponse = NextResponse.next({ 6 | request, 7 | }) 8 | 9 | const supabase = createServerClient( 10 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 11 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 12 | { 13 | cookies: { 14 | getAll() { 15 | return request.cookies.getAll() 16 | }, 17 | setAll(cookiesToSet) { 18 | cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value)) 19 | supabaseResponse = NextResponse.next({ 20 | request, 21 | }) 22 | cookiesToSet.forEach(({ name, value, options }) => 23 | supabaseResponse.cookies.set(name, value, options) 24 | ) 25 | }, 26 | }, 27 | } 28 | ) 29 | 30 | // IMPORTANT: Avoid writing any logic between createServerClient and 31 | // supabase.auth.getUser(). A simple mistake could make it very hard to debug 32 | // issues with users being randomly logged out. 33 | const { 34 | data: { user }, 35 | } = await supabase.auth.getUser() 36 | const url = request.nextUrl.clone() 37 | 38 | if (request.nextUrl.pathname.startsWith('/webhook')) { 39 | return supabaseResponse 40 | } 41 | 42 | if ( 43 | !user && 44 | !request.nextUrl.pathname.startsWith('/login') && 45 | !request.nextUrl.pathname.startsWith('/auth') && 46 | !request.nextUrl.pathname.startsWith('/signup') && 47 | !request.nextUrl.pathname.startsWith('/forgot-password') && 48 | !(request.nextUrl.pathname === '/') 49 | ) { 50 | // no user, potentially respond by redirecting the user to the login page 51 | url.pathname = '/login' 52 | return NextResponse.redirect(url) 53 | } 54 | // // If user is logged in, redirect to dashboard 55 | if (user && request.nextUrl.pathname === '/') { 56 | url.pathname = '/dashboard' 57 | return NextResponse.redirect(url) 58 | } 59 | // IMPORTANT: You *must* return the supabaseResponse object as it is. If you're 60 | // creating a new response object with NextResponse.next() make sure to: 61 | // 1. Pass the request in it, like so: 62 | // const myNewResponse = NextResponse.next({ request }) 63 | // 2. Copy over the cookies, like so: 64 | // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) 65 | // 3. Change the myNewResponse object to fit your needs, but avoid changing 66 | // the cookies! 67 | // 4. Finally: 68 | // return myNewResponse 69 | // If this is not done, you may be causing the browser and server to go out 70 | // of sync and terminate the user's session prematurely! 71 | 72 | return supabaseResponse 73 | } -------------------------------------------------------------------------------- /utils/supabase/server.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient, type CookieOptions } from '@supabase/ssr' 2 | import { cookies } from 'next/headers' 3 | 4 | export function createClient() { 5 | const cookieStore = cookies() 6 | 7 | return createServerClient( 8 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 9 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 10 | { 11 | cookies: { 12 | getAll() { 13 | return cookieStore.getAll() 14 | }, 15 | setAll(cookiesToSet) { 16 | try { 17 | cookiesToSet.forEach(({ name, value, options }) => 18 | cookieStore.set(name, value, options) 19 | ) 20 | } catch { 21 | // The `setAll` method was called from a Server Component. 22 | // This can be ignored if you have middleware refreshing 23 | // user sessions. 24 | } 25 | }, 26 | }, 27 | } 28 | ) 29 | } --------------------------------------------------------------------------------