2024-10-31 Web Development

Building a Full-Stack CRUD Application with Next.js 15 and Supabase Authentication

By O. Wolfson

Next.js 15 brings new enhancements, including Promise-based searchParams, improved server performance, and a seamless integration with React 19. In this guide, we’ll leverage these features to build a secure CRUD application with Supabase authentication.


1. Initial Setup and Installation

First, set up your environment by installing the required Supabase packages:

bash
npm install @supabase/supabase-js @supabase/ssr

Then, in your project root, create a .env.local file to store your Supabase environment variables:

plaintext
NEXT_PUBLIC_SUPABASE_URL=<your_supabase_project_url>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your_supabase_anon_key>

These values are available in your Supabase dashboard.


2. Configuring Supabase Client Utilities

We’ll create two client utilities for Supabase: one for client components and one for server components.

Client Component Client (utils/supabase/client.ts)

typescript
import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""
  );
}

Server Component Client (utils/supabase/server.ts)

The server client requires handling cookies securely. In Next.js 15, the cookies API is asynchronous:

typescript
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          for (const { name, value, options } of cookiesToSet) {
            cookieStore.set(name, value, options);
          }
        },
      },
    }
  );
}

3. Setting Up Middleware for Session Refresh and Authentication

Next, we’ll add middleware to refresh tokens and redirect users when necessary.

Create a middleware.ts file in your project root:

typescript
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|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

Create a helper for this in utils/supabase/middleware.ts:

typescript
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) {
          for (const { name, value } of cookiesToSet) {
            request.cookies.set(name, value);
          }
          supabaseResponse = NextResponse.next({
            request,
          });
          for (const { name, value, options } of cookiesToSet) {
            supabaseResponse.cookies.set(name, value, options);
          }
        },
      },
    }
  );

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

  if (!user && !request.nextUrl.pathname.startsWith("/login")) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

This middleware handles user sessions by ensuring that expired tokens are refreshed.


4. Creating Login and Signup Pages with Server Actions

Now, we’ll build our login and signup pages, utilizing server actions for secure, server-side authentication.

Login Page (app/login/page.tsx):

typescript
import { login, signup } from "./actions";

export default function LoginPage() {
  return (
    <form>
      <label htmlFor="email">Email:</label>
      <input id="email" name="email" type="email" required />
      <label htmlFor="password">Password:</label>
      <input id="password" name="password" type="password" required />
      <button formAction={login}>Log in</button>
      <button formAction={signup}>Sign up</button>
    </form>
  );
}

Server Actions (app/login/actions.ts):

typescript
"use server";

import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";

export async function login(formData: FormData) {
  const supabase = await createClient();
  const { error } = await supabase.auth.signInWithPassword({
    email: formData.get("email") as string,
    password: formData.get("password") as string,
  });

  if (error) redirect("/error");
  redirect("/");
}

export async function signup(formData: FormData) {
  const supabase = await createClient();
  const { error } = await supabase.auth.signUp({
    email: formData.get("email") as string,
    password: formData.get("password") as string,
  });

  if (error) redirect("/error");
  redirect("/");
}

5. Implementing CRUD Operations

With authentication in place, let’s implement our basic CRUD functionality. Here’s an example of a page that reads and displays data:

Data Page (app/data/page.tsx):

typescript
import { createClient } from "@/utils/supabase/server";

export default async function DataPage() {
  const supabase = await createClient();
  const { data, error } = await supabase.from("my_table").select("*");

  if (error) return <p>Error loading data</p>;

  return (
    <div>
      {data?.map((item) => (
        <p key={item.id}>{item.name}</p>
      ))}
    </div>
  );
}

To create, update, or delete records, similar functions can be created as Server Actions that call Supabase methods like insert, update, and delete.


6. Adding Auth Confirmation and Private Routes

If email confirmation is enabled, users will need to confirm their account. Modify the email confirmation URL in Supabase to direct users to auth/confirm with the token.

Auth Confirmation Route (app/auth/confirm/route.ts):

typescript
import { type EmailOtpType } from "@supabase/supabase-js";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";

export async function GET({ url }: { url: URL }) {
  const searchParams = new URL(url).searchParams;
  const token_hash = searchParams.get("token_hash");
  const type = searchParams.get("type") as EmailOtpType;

  if (token_hash && type) {
    const supabase = await createClient();
    const { error } = await supabase.auth.verifyOtp({ type, token_hash });
    if (!error) redirect("/");
  }

  redirect("/error");
}

For private routes, use supabase.auth.getUser() to check the session.


Conclusion

We’ve covered the full process of setting up a secure Next.js 15 CRUD application with Supabase authentication. This includes configuring clients, setting up middleware, creating login pages, implementing CRUD functions, and handling private routes and email confirmation. This structure provides a solid foundation for secure, authenticated CRUD applications.


Let me know if you'd like to expand on any section, add more examples, or clarify specific points!