JournalDesigning and Building a full Authentication & Payment System

NextJS/Authentication/Payments

Designing and Building a full Authentication & Payment System

Thumbnail for the project
Ahmed Alsayad - effekt.design
Ahmed Alsayad
26 August 2024

In web development, tackling authentication, app security, database management, and payment integration can be tough. These areas are complex and require deep experience or lots of trial and error to get right.

A big pain point in new projects is the lengthy setup. Setting up authentication, security, databases, and payments from scratch can be overwhelming, often leading to frustration and inefficiency.

Why I started this project

Recognizing these challenges, I was motivated to create a boilerplate that addresses these challenges from the start. It aims to streamline the setup process for authentication, security, and payments, saving time and providing a more secure, scalable foundation.

The goal was to build an efficient, optimized boilerplate to make project kickoffs smoother. There's also a plan to share this on GitHub so others can benefit from this streamlined approach, focusing more on building applications rather than setup.

This boilerplate is designed to solve common pain points, offering a secure and scalable base for future projects while contributing to the developer community.

Authentication🔐

Authentication is vital yet challenging. Poor implementation can lead to security issues. This boilerplate includes Supabase-powered authentication, with email/password sign-up, OAuth integration, and email verification, all secured with middleware for protected routes.

Email and Password Authentication:

Although considered old school now, I started by implementing secure email and password authentication since its fundamental to ensuring that users can safely create accounts and access the application.

However, I was presented with the first challenge when it comes to supabase.
Supbase did not return an error if an email exsists so I needed to implement an API to check for the exsistence of a user first

export async function signUpWithEmailAndPassword(
  supabase: SupabaseClient,
  email: string,
  password: string,
  userExtraMetaData?: any
) {
//Since Supabase doesn't provide an error if an email already exsists, I needed to implement an api that fetches from the users table to check that
  const response = await fetch('/api/get-user', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email }),
  });

  if (!response.ok) {
    const { error } = await response.json();
    throw Error(error || 'An error occurred during the email check');
  }

  const { user } = await response.json();
  if (user) throw Error('Email already exists');

  const {
    data: { user: signUpUser },
    error: signUpError,
  } = await supabase.auth.signUp({
    email: email,
    password: password,
    options: {
      emailRedirectTo: process.env.NEXT_PUBLIC_REDIRECT_URL,
      data: userExtraMetaData,
    },
  });

  if (signUpError) throw signUpError;

  //After signing up in the auth table add to users table as unverified
  // Prepare user details
  const userDetails: InsertUser = {
    authData: signUpUser,
    name: signUpUser?.user_metadata.name,
    email: signUpUser?.user_metadata.email,
    provider: signUpUser?.app_metadata.provider || 'Unknown',
    picture: signUpUser?.user_metadata.avatar_url || getRandomColor(),
    created_at: new Date(),
    updated_at: new Date(),
    verified: false,
    membership: null,
  };

  // Insert new user if not found
  const { error: insertError } = await supabase.from('users').insert(userDetails);
  if (insertError) throw insertError;

  return;
}

This with addition to some other actions I was able to create a modular functional system to support the rest of the Authentication system.

  1. User Signup
  2. Email Verification
  3. User Login
  4. Password Reset
  5. OAuth Integration

Middleware for Protected Routes

For a functional authentication system we need a robust middleware.

import { type NextRequest } from 'next/server';
import { updateSession } from '@/utils/supabase/middleware';

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|api/*|verify|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Thankfully, supabase made it super simple to implement a system for protecting sensitive routes.

import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value));
          supabaseResponse = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options));
        },
      },
    }
  );

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user && !request.nextUrl.pathname.startsWith('/login') && !request.nextUrl.pathname.startsWith('/auth')) {
    // no user, potentially respond by redirecting the user to the login page
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    return NextResponse.redirect(url);
  }

  // Redirect authenticated users away from the login page
  if (user && request.nextUrl.pathname.startsWith('/login')) {
    const url = request.nextUrl.clone();
    url.pathname = '/';
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

Database and ORM 🗃️

Managing databases can be tricky, especially ensuring type safety. The boilerplate integrates Supabase and Drizzle ORM, providing a robust database solution with auto-linked auth data and TypeScript schema definitions for smooth, error-free development.

import { User } from '@supabase/supabase-js';
import { boolean, jsonb, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';

export type Membership = {
  productName: string;
  price: string;
  purchasedAt: string;
  userId: string;
  userEmail: string;
};

export const usersTable = pgTable('users', {
  id: serial('id').notNull(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  authData: jsonb('authData').notNull(),
  verified: boolean('verified').default(false),
  picture: text('picture'),
  provider: text('provider').notNull(),
  created_at: timestamp('created_at').defaultNow().notNull(),
  updated_at: timestamp('updated_at').defaultNow().notNull(),
  membership: jsonb('membership'),
});

export type InsertUser = Omit<typeof usersTable.$inferInsert, 'authData' | 'membership'> & {
  authData: User | null;
  membership: Membership | null;
};

export type SelectUser = Omit<typeof usersTable.$inferSelect, 'authData' | 'membership'> & {
  authData: User;
  membership: Membership | null;
};

Adding types instead of the jsonb schema columns made it easier for me to develop my actions and functions due to the extended types for SelectUser and Insert User

Database would be updated after every user action like for example when a user deletes his account.

Payments 💸

Secure payments are crucial for transaction-based apps. This boilerplate uses Lemon Squeezy for payments, offering secure checkout links and real-time updates via webhooks, ensuring smooth and safe payment processes.

To manage payment processing, Lemon Squeezy was integrated to handle tasks like generating secure checkout links and processing payments. This ensures that users can easily purchase products or services directly from the application. Secure Checkout Links: Users can generate secure checkout links to complete transactions, ensuring that payment information is handled safely.

Webhook Handling

The challenging part about LemonSqueezy was the webhook integration. It's main aim was to communicate instantley to the front end that there has been a purchase on a specific account.

Webhooks are set up to provide real-time updates when a purchase is completed. This feature ensures that the user interface and database are updated immediately, giving users instant access to their purchased items or services.

import crypto from 'node:crypto';
import { webhookHasMeta } from '@/lib/typegaurd';
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { Membership } from '@/db/schema';

// Create a Supabase client using the service role key
const supabaseAdmin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE!);


export async function POST(request: NextRequest) {
  if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) {
    return new Response('Lemon Squeezy Webhook Secret not set in .env', {
      status: 500,
    });
  }


  // Get the raw body content.
  const rawBody = await request.text();

  // Get the webhook secret from the environment variables.
  const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;

  const hmac = crypto.createHmac('sha256', secret);
  const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');
  const signature = Buffer.from(request.headers.get('X-Signature') || '', 'utf8');

  if (!crypto.timingSafeEqual(digest, signature)) {
    return NextResponse.json({ message: 'Invalid Signature' }, { status: 400 });
  }


// Valid request                           
  const data = JSON.parse(rawBody) as LemonSqueezyWebhook;

  // Type guard to check if the object has a 'meta' property.
  if (webhookHasMeta(data)) {
    console.log('Webhook Received');

    // Extract membership details
    const membershipDetails: Membership = {
      productName: data.data.attributes.first_order_item.product_name,
      price: data.data.attributes.total_formatted,
      purchasedAt: data.data.attributes.created_at,
      userId: data.meta.custom_data.user_id,
      userEmail: data.meta.custom_data.user_email,
    };

    //Update details on supabase
    const { error } = await supabaseAdmin
      .from('users')
      .update({ membership: membershipDetails })
      .eq('authData->>id', membershipDetails.userId);

    if (error) return NextResponse.json({ error: error.message }, { status: 400 });
    return NextResponse.json({ message: 'Webhook Received & user updated on supabase users' }, { status: 200 });
  }

  return NextResponse.json({ error: 'Data invalid' }, { status: 500 });
}

File Management and Storage 🗂️

File management is critical for many apps. This boilerplate integrates AWS S3 for scalable, secure storage, allowing users to manage profile pictures and other files efficiently, with future plans to add AWS Lambda for advanced media processing.

UI/UX Components 🎨

A good UI is key to user experience. The boilerplate includes reusable form components and global modal management to streamline UI development, ensuring a consistent, user-friendly interface.

Since I had to create a lot of forms I found a way to create a reuable and type safe forms using Zod.

import React from 'react';
import cn from '@/utils/general/cn';
import { useForm, Path } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { ZodType } from 'zod';
import * as z from 'zod';
import { FaCheck, FaSpinner, FaXmark } from 'react-icons/fa6';

interface FormField<T> {
  name: Path<T>; // Ensures that the name is a valid key of the form schema
  label: string;
  type?: string;
  underField?: React.ReactNode;
}

interface FormWrapperProps<T extends ZodType<any, any>> {
  heading?: string;
  subheading?: string;
  fields: FormField<z.infer<T>>[];
  zodSchema: T;
  onSubmit: (data: z.infer<T>) => void; // Callback to pass form data to parent
  formStatus: 'idle' | 'submitting' | 'success' | 'error'; // Form status prop
  formMessage: string | null; // Form message prop
  children: React.ReactNode;
  furtherAction?: React.ReactNode;
}

const FormWrapper = <T extends ZodType<any, any>>({
  heading,
  subheading,
  fields,
  zodSchema,
  onSubmit, // Callback prop
  children,
  formStatus,
  formMessage,
  furtherAction,
}: FormWrapperProps<T>) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<z.infer<T>>({
    resolver: zodResolver(zodSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} className='w-full'>
      <div className={`${heading || subheading ? 'mb-9' : 'm-0'}`}>
        <h1 className='heading-1'>{heading}</h1>
        <p className='subheading-1'>{subheading}</p>
      </div>
      {fields.map(({ name, label, type = 'text', underField }) => (
        <div key={name as string} className='relative mb-4 w-full flex flex-col'>
          <label className='form-label' htmlFor={name}>
            {label}
          </label>
          <input
            className={cn('form-input', {
              'border-red-500/30 focus:border-red-600/60': errors[name],
            })}
            type={type}
            {...register(name)}
          />
          {underField && (
            <span className='text-[13px] mt-0.5 hover:underline transition-100 cursor-pointer self-end'>
              {underField}
            </span>
          )}
          {errors[name] && (
            <span className='absolute top-0 right-0 md:text-xs text-red-400'>
              {(errors[name]?.message as string) || 'This field is required'}
            </span>
          )}
        </div>
      ))}
      {children}
      {formStatus !== 'idle' && formMessage && (
        <div
          className={cn(
            'py-2 px-3 w-full mt-4 text-center rounded-lg text-sm flex items-center justify-center',
            formStatus === 'submitting' && 'bg-black/5 text-black',
            formStatus === 'success' && 'bg-green-100 text-green-700',
            formStatus === 'error' && 'bg-red-100 text-red-700'
          )}
        >
          {formStatus === 'submitting' && <FaSpinner className='w-3 animate-spin opacity-40 mr-2' />}
          {formStatus === 'success' && <FaCheck className='w-3 opacity-40 mr-2' />}
          {formStatus === 'error' && <FaXmark className='w-4 opacity-40 mr-2' />}
          {formMessage}
          {furtherAction && furtherAction}
        </div>
      )}
    </form>
  );
};

export default FormWrapper;

Emailing System 📧

A robust email system is essential for user communications. Supabase’s email system is used to handle automated emails for key actions like password resets and email verification, ensuring timely notifications for users.

Conclusion 🚀

This boilerplate is a significant step towards creating a strong, secure, and scalable foundation for web development. It simplifies the setup process, allowing developers to focus on building applications. However, this is just the beginning, with plans to add subscription-based payments, advanced database management, and modular UI components.

I'm sharing these learnings and looking forward to continuing the journey of refining this tool. There’s still more work ahead to unlock its full potential, but I'm excited to see where it goes and to share these findings with the community.

figma icon - effekt.designGithub icon - effekt.design

Want unlimited access to all source code & design files?

Join effekt.community now & get premium community access

Join Community