Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321"
SUPABASE_SERVICE_ROLE_KEY=

# Get these from Stripe dashboard
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_placeholder"
STRIPE_SECRET_KEY="sk_test_placeholder"
STRIPE_WEBHOOK_SECRET=
6 changes: 6 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}
35 changes: 23 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# Next.js Subscription Payments Starter


> [!WARNING]
> This repo has been sunset and replaced by a new template: https://github.com/nextjs/saas-starter

## Features

- Secure user management and authentication with [Supabase](https://supabase.io/docs/guides/auth)
Expand All @@ -29,7 +25,7 @@ When deploying this template, the sequence of steps is important. Follow the ste

#### Vercel Deploy Button

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments&env=NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY&envDescription=Enter%20your%20Stripe%20API%20keys.&envLink=https%3A%2F%2Fdashboard.stripe.com%2Fapikeys&project-name=nextjs-subscription-payments&repository-name=nextjs-subscription-payments&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments%2Ftree%2Fmain)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase-community%2Fnextjs-subscription-payments&env=NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY&envDescription=Enter%20your%20Stripe%20API%20keys.&envLink=https%3A%2F%2Fdashboard.stripe.com%2Fapikeys&project-name=nextjs-subscription-payments&repository-name=nextjs-subscription-payments&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fsupabase-community%2Fnextjs-subscription-payments%2Ftree%2Fmain)

The Vercel Deployment will create a new repository with this template on your GitHub account and guide you through a new Supabase project creation. The [Supabase Vercel Deploy Integration](https://vercel.com/integrations/supabase) will set up the necessary Supabase environment variables and run the [SQL migrations](./supabase/migrations/20230530034630_init.sql) to set up the Database schema on your account. You can inspect the created tables in your project's [Table editor](https://app.supabase.com/project/_/editor).

Expand Down Expand Up @@ -71,12 +67,21 @@ For the following steps, make sure you have the ["Test Mode" toggle](https://str

We need to create a webhook in the `Developers` section of Stripe. Pictured in the architecture diagram above, this webhook is the piece that connects Stripe to your Vercel Serverless Functions.

1. Click the "Add Endpoint" button on the [test Endpoints page](https://dashboard.stripe.com/test/webhooks).
1. Enter your production deployment URL followed by `/api/webhooks` for the endpoint URL. (e.g. `https://your-deployment-url.vercel.app/api/webhooks`)
1. Click `Select events` under the `Select events to listen to` heading.
1. Click `Select all events` in the `Select events to send` section.
1. Copy `Signing secret` as we'll need that in the next step (e.g `whsec_xxx`) (/!\ be careful not to copy the webook id we_xxxx).
1. In addition to the `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and the `STRIPE_SECRET_KEY` we've set earlier during deployment, we need to add the webhook secret as `STRIPE_WEBHOOK_SECRET` env var.
1. Click the "Add Endpoint" (or "Create an event destination") button on the [test Endpoints page](https://dashboard.stripe.com/test/webhooks).
2. **Important:** When selecting events, you may see a notice about "Snapshot payloads" vs "Thin payloads". This template uses **snapshot payloads** (the traditional format). Only select events that use snapshot payloads, or if given the option, skip creating a destination for thin payloads.
3. Select the following event types (these all use snapshot payloads):
- `customer.*`
- `product.*`
- `price.*`
- `checkout.session.*`
- `invoice.*`
- `subscription.*`

Alternatively, you can select "All events" but be aware you may need to create separate destinations for different payload styles.
4. Click "Continue" and select "Webhook endpoint" as the destination type.
5. Enter your production deployment URL followed by `/api/webhooks` for the endpoint URL. (e.g. `https://your-deployment-url.vercel.app/api/webhooks`)
6. Copy the `Signing secret` as we'll need that in the next step (e.g `whsec_xxx`) (/!\ be careful not to copy the webhook id `we_xxxx`).
7. In addition to the `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and the `STRIPE_SECRET_KEY` we've set earlier during deployment, we need to add the webhook secret as `STRIPE_WEBHOOK_SECRET` env var.

#### Redeploy with new env vars

Expand Down Expand Up @@ -148,7 +153,13 @@ Running this command will create a new `.env.local` file in your project folder.

It's highly recommended to use a local Supabase instance for development and testing. We have provided a set of custom commands for this in `package.json`.

First, you will need to install [Docker](https://www.docker.com/get-started/). You should also copy or rename:
First, you will need to install [Docker](https://www.docker.com/get-started/). You will also need to install the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started):

```bash
brew install supabase/tap/supabase
```

Next, copy or rename:

- `.env.local.example` -> `.env.local`
- `.env.example` -> `.env`
Expand Down
4 changes: 2 additions & 2 deletions app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '@/utils/supabase/queries';

export default async function Account() {
const supabase = createClient();
const supabase = await createClient();
const [user, userDetails, subscription] = await Promise.all([
getUser(supabase),
getUserDetails(supabase),
Expand Down Expand Up @@ -40,4 +40,4 @@ export default async function Account() {
</div>
</section>
);
}
}
11 changes: 6 additions & 5 deletions app/api/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ export async function POST(req: Request) {
let event: Stripe.Event;

try {
if (!sig || !webhookSecret)
return new Response('Webhook secret not found.', { status: 400 });
if (!stripe || !sig || !webhookSecret)
return new Response('Stripe or webhook configuration not found.', { status: 400 });
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
console.log(`🔔 Webhook received: ${event.type}`);
} catch (err: any) {
console.log(`❌ Error message: ${err.message}`);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
console.log(`❌ Error message: ${errorMessage}`);
return new Response(`Webhook Error: ${errorMessage}`, { status: 400 });
}

if (relevantEvents.has(event.type)) {
Expand Down
2 changes: 1 addition & 1 deletion app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
const code = requestUrl.searchParams.get('code');

if (code) {
const supabase = createClient();
const supabase = await createClient();

const { error } = await supabase.auth.exchangeCodeForSession(code);

Expand Down
2 changes: 1 addition & 1 deletion app/auth/reset_password/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
const code = requestUrl.searchParams.get('code');

if (code) {
const supabase = createClient();
const supabase = await createClient();

const { error } = await supabase.auth.exchangeCodeForSession(code);

Expand Down
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@/utils/supabase/queries';

export default async function PricingPage() {
const supabase = createClient();
const supabase = await createClient();
const [user, products, subscription] = await Promise.all([
getUser(supabase),
getProducts(supabase),
Expand Down
34 changes: 17 additions & 17 deletions app/signin/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { redirect } from 'next/navigation';
import {
getAuthTypes,
getViewTypes,
getDefaultSignInView,
getRedirectMethod
getDefaultSignInView
} from '@/utils/auth-helpers/settings';
import Card from '@/components/ui/Card';
import PasswordSignIn from '@/components/ui/AuthForms/PasswordSignIn';
Expand All @@ -21,28 +20,32 @@ export default async function SignIn({
params,
searchParams
}: {
params: { id: string };
searchParams: { disable_button: boolean };
params: Promise<{ id: string }>;
searchParams: Promise<{ disable_button: boolean }>;
}) {
// Await params and searchParams in Next.js 15
const { id } = await params;
const { disable_button } = await searchParams;

const { allowOauth, allowEmail, allowPassword } = getAuthTypes();
const viewTypes = getViewTypes();
const redirectMethod = getRedirectMethod();

// Declare 'viewProp' and initialize with the default value
let viewProp: string;

// Assign url id to 'viewProp' if it's a valid string and ViewTypes includes it
if (typeof params.id === 'string' && viewTypes.includes(params.id)) {
viewProp = params.id;
if (typeof id === 'string' && viewTypes.includes(id)) {
viewProp = id;
} else {
const cookieStore = await cookies();
const preferredSignInView =
cookies().get('preferredSignInView')?.value || null;
cookieStore.get('preferredSignInView')?.value || null;
viewProp = getDefaultSignInView(preferredSignInView);
return redirect(`/signin/${viewProp}`);
}

// Check if the user is already logged in and redirect to the account page if so
const supabase = createClient();
const supabase = await createClient();

const {
data: { user }
Expand Down Expand Up @@ -74,28 +77,25 @@ export default async function SignIn({
{viewProp === 'password_signin' && (
<PasswordSignIn
allowEmail={allowEmail}
redirectMethod={redirectMethod}
/>
)}
{viewProp === 'email_signin' && (
<EmailSignIn
allowPassword={allowPassword}
redirectMethod={redirectMethod}
disableButton={searchParams.disable_button}
disableButton={disable_button}
/>
)}
{viewProp === 'forgot_password' && (
<ForgotPassword
allowEmail={allowEmail}
redirectMethod={redirectMethod}
disableButton={searchParams.disable_button}
disableButton={disable_button}
/>
)}
{viewProp === 'update_password' && (
<UpdatePassword redirectMethod={redirectMethod} />
<UpdatePassword />
)}
{viewProp === 'signup' && (
<SignUp allowEmail={allowEmail} redirectMethod={redirectMethod} />
<SignUp allowEmail={allowEmail} />
)}
{viewProp !== 'update_password' &&
viewProp !== 'signup' &&
Expand All @@ -109,4 +109,4 @@ export default async function SignIn({
</div>
</div>
);
}
}
5 changes: 3 additions & 2 deletions app/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { redirect } from 'next/navigation';
import { getDefaultSignInView } from '@/utils/auth-helpers/settings';
import { cookies } from 'next/headers';

export default function SignIn() {
export default async function SignIn() {
const cookieStore = await cookies();
const preferredSignInView =
cookies().get('preferredSignInView')?.value || null;
cookieStore.get('preferredSignInView')?.value || null;
const defaultView = getDefaultSignInView(preferredSignInView);

return redirect(`/signin/${defaultView}`);
Expand Down
2 changes: 1 addition & 1 deletion components/ui/AccountForms/CustomerPortalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function CustomerPortalForm({ subscription }: Props) {
subscription &&
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: subscription?.prices?.currency!,
currency: subscription.prices?.currency || 'USD',
minimumFractionDigits: 0
}).format((subscription?.prices?.unit_amount || 0) / 100);

Expand Down
6 changes: 2 additions & 4 deletions components/ui/AuthForms/EmailSignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,14 @@ import { useState } from 'react';
// Define prop type with allowPassword boolean
interface EmailSignInProps {
allowPassword: boolean;
redirectMethod: string;
disableButton?: boolean;
}

export default function EmailSignIn({
allowPassword,
redirectMethod,
disableButton
}: EmailSignInProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
Expand Down Expand Up @@ -69,7 +67,7 @@ export default function EmailSignIn({
</p>
<p>
<Link href="/signin/signup" className="font-light text-sm">
Don't have an account? Sign up
Don&apos;t have an account? Sign up
</Link>
</p>
</>
Expand Down
6 changes: 2 additions & 4 deletions components/ui/AuthForms/ForgotPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,14 @@ import { useState } from 'react';
// Define prop type with allowEmail boolean
interface ForgotPasswordProps {
allowEmail: boolean;
redirectMethod: string;
disableButton?: boolean;
}

export default function ForgotPassword({
allowEmail,
redirectMethod,
disableButton
}: ForgotPasswordProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
Expand Down Expand Up @@ -74,7 +72,7 @@ export default function ForgotPassword({
)}
<p>
<Link href="/signin/signup" className="font-light text-sm">
Don't have an account? Sign up
Don&apos;t have an account? Sign up
</Link>
</p>
</div>
Expand Down
8 changes: 3 additions & 5 deletions components/ui/AuthForms/PasswordSignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,12 @@ import React, { useState } from 'react';
// Define prop type with allowEmail boolean
interface PasswordSignInProps {
allowEmail: boolean;
redirectMethod: string;
}

export default function PasswordSignIn({
allowEmail,
redirectMethod
allowEmail
}: PasswordSignInProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
Expand Down Expand Up @@ -80,7 +78,7 @@ export default function PasswordSignIn({
)}
<p>
<Link href="/signin/signup" className="font-light text-sm">
Don't have an account? Sign up
Don&apos;t have an account? Sign up
</Link>
</p>
</div>
Expand Down
5 changes: 2 additions & 3 deletions components/ui/AuthForms/Signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ import { useState } from 'react';
// Define prop type with allowEmail boolean
interface SignUpProps {
allowEmail: boolean;
redirectMethod: string;
}

export default function SignUp({ allowEmail, redirectMethod }: SignUpProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
export default function SignUp({ allowEmail }: SignUpProps) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
Expand Down
10 changes: 2 additions & 8 deletions components/ui/AuthForms/UpdatePassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,8 @@ import { handleRequest } from '@/utils/auth-helpers/client';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';

interface UpdatePasswordProps {
redirectMethod: string;
}

export default function UpdatePassword({
redirectMethod
}: UpdatePasswordProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
export default function UpdatePassword() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
Expand Down
4 changes: 2 additions & 2 deletions components/ui/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import cn from 'classnames';

import s from './Input.module.css';

interface Props extends Omit<InputHTMLAttributes<any>, 'onChange'> {
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
className?: string;
onChange: (value: string) => void;
}
const Input = (props: Props) => {
const { className, children, onChange, ...rest } = props;
const { className, onChange, ...rest } = props;

const rootClassName = cn(s.root, {}, className);

Expand Down
2 changes: 1 addition & 1 deletion components/ui/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import s from './Navbar.module.css';
import Navlinks from './Navlinks';

export default async function Navbar() {
const supabase = createClient();
const supabase = await createClient();

const {
data: { user }
Expand Down
Loading