├── .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 |
2 |
3 |
4 |
5 |
6 |
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 | 
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 |
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 | 
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 | 
73 |
74 |
75 | 9. Add `STRIPE_PUBLISHABLE_KEY` and `STRIPE_PRICING_TABLE_ID` to `.env`
76 | 
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
16 | )}
17 | {isGithubEnabled && (
18 |
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 |
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 |
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 | }
--------------------------------------------------------------------------------