T3 Stack Template : Supabase (w/ Auth + DB) and Shadcn-UI Basic Setup

RemusRemus
22 min read

Introduction

In this guide, we will cover how to convert the base T3 stack config to use Supabase auth instead of NextAuth.js coupled with Supabase DB and Shadcn-UI. We'll also cover how to use SQL triggers so that we can use the more stable single-schema db with Prisma and still get access to the Supabase client-side APIs.

Starting Out

npm create t3-app@latest

You can use pnpm or yarn instead of npm

T3 setup Flow

  1. Create Project Name

  2. Typescript (only typescript)

  3. You can use nextAuth, prisma, tailwind and trpc, nextAuth is optional as we’re going to change that to supabase auth so whether you click yes on that option it doesn’t really matter since it’s going to be changed anyways.

Setting Up the .env & .env.example files

Go to the .env.example and copy the lines below, this will be a reference to modifying the .env file with all the environment variables

# PostgreSQL connection string with pgBouncer config — used by Prisma Client
DATABASE_URL = "insert_database_url_here"

# PostgreSQL connection string used for migrations
DIRECT_URL="insert_direct_url_here"

# Supabase
NEXT_PUBLIC_SUPABASE_URL="insert_supabase_public_url"
NEXT_PUBLIC_SUPABASE_ANON_KEY="insert_supabase_anon_key"

After creating a new project in Supabase, go to the project settings at the gear icon in the sidebar, then click on API.

Project Setting -> API

Grab both the Supabase URL and API keys

Copy the Supabase URL in the NEXT_PUBLIC_SUPABASE_URL variable in the .env file

Copy anon key in the NEXT_PUBLIC_SUPABASE_ANON_KEY variable in .env file

Switch from the API to the Database Menu

Project Settings -> Database -> Connection Pooling -> Connection String

Project Settings -> Database -> Connection Pooling -> Connection String

Copy the Connection String URI in the DATABASE_URL variable in the .env file — should end with 5432

Copy the Connection Pooling - Connection String in the DIRECT_URL variable in .env file — should end with 6543

Add your password in the area below, REPLACING the brackets highlighted in green

NOT in the area below (inside the brackets)

Single-Schema DB + auth.users table trigger

The codebase below provided much of the inspiration for making this tutorial, however, it uses a multi-schema db which introduced some challenges when trying to access Supabase from the SupabaseJS client-side library.

https://github.com/supabase-community/create-t3-turbo

Due to there being some issues with multi-schema databases, I recommend using a single-schema database until those issues get resolved as it’s still a preview feature in Prisma as of when this article was published. Below is a GitHub thread discussing some real-world examples of the challenges of using the multi-schema db option in Prisma.

https://github.com/supabase/gotrue/issues/1061

Below is a thread from the Supabase discord channel where I posted the challenges I was having trying to modify the public schema.

Discord - A New Way to Chat with Friends & Communities

Below is the official guide from the Supabase docs on how to set up a Supabase project using a single schema db, however, it does not leverage the t3 stack. We’re going to be using the example SQL code for the auth.users trigger so the user_id variable can be accessed from the public.users table.

Build a User Management App with NextJS | Supabase Docs

SQL code reference

Below is the SQL users table trigger directly from the user management Supabase demo app.

-- Create a table for public profiles
create table profiles (
  id uuid references auth.users not null primary key,
  updated_at timestamp with time zone,
  username text unique,
  full_name text,
  avatar_url text,
  website text,

  constraint username_length check (char_length(username) >= 3)
);
-- Set up Row Level Security (RLS)
-- See <https://supabase.com/docs/guides/auth/row-level-security> for more details.
alter table profiles
  enable row level security;

create policy "Public profiles are viewable by everyone." on profiles
  for select using (true);

create policy "Users can insert their own profile." on profiles
  for insert with check (auth.uid() = id);

create policy "Users can update own profile." on profiles
  for update using (auth.uid() = id);

-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
-- See <https://supabase.com/docs/guides/auth/managing-user-data#using-triggers> for more details.
create function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, full_name, avatar_url)
  values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
  return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

-- Set up Storage!
insert into storage.buckets (id, name)
  values ('avatars', 'avatars');

-- Set up access controls for storage.
-- See <https://supabase.com/docs/guides/storage#policy-examples> for more details.
create policy "Avatar images are publicly accessible." on storage.objects
  for select using (bucket_id = 'avatars');

create policy "Anyone can upload an avatar." on storage.objects
  for insert with check (bucket_id = 'avatars');

create policy "Anyone can update their own avatar." on storage.objects
  for update using (auth.uid() = owner) with check (bucket_id = 'avatars');

We’re not going to need most of what is used because we’re not using Supabase storage for this example but you're mileage may vary. Instead of profiles we’re going to call this table users.

-- Create a table for public users without a foreign key reference
CREATE TABLE users (
  user_id uuid NOT NULL PRIMARY KEY,
  updated_at timestamp with time zone
);

-- Set up Row Level Security (RLS)
ALTER TABLE users
ENABLE row level security;

-- Public users are viewable by everyone
CREATE POLICY "public_users_are_viewable_by_everyone" ON users
FOR SELECT USING (true);

-- Users can insert their own user
CREATE POLICY "users_can_insert_their_own_user" ON users
FOR INSERT WITH CHECK (auth.uid()::uuid = user_id);

-- Users can update their own user
CREATE POLICY "users_can_update_own_user" ON users
  FOR UPDATE USING (auth.uid()::uuid = user_id);

-- This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
CREATE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
  INSERT INTO public.users (user_id)
  VALUES (new.id);
  RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created_v2
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();

Add the trigger in the SQL Editor

Go the SQL Editor in the Supabase dashboard for your project

Other SQL Trigger Articles

Below are some other reference articles for creating auth triggers for single-schema Supabase projects.

https://www.sandromaglione.com/techblog/supabase-database-user-sign-up-and-row-level-security

https://nextdev1111.hashnode.dev/triggers-in-supabase-for-making-new-rows

Set Up signin.tsx and signup.tsx

With the SQL code set up, we can go to the signin and signup pages next. Both the signin and signup functionality can be merged into one page but we’re just going to separate them out for clarity.

Download and Setup Shadcn-UI

Below is the page for getting started with Shadcn-UI, for a NextJS project.

https://ui.shadcn.com/docs/installation/next

pnpm dlx shadcn-ui@latest init

You can yarn or npm if your project needs other options.

Select the options above

  • Yes for src/ directory

  • No for the app router (this might change if t3 stack switches to the app router)

  • No

In a default T3-app configuration the globals.css is in the root → src → styles → globals.css

Shadcn UI Components

There’s also a manual installation available, but since the Shadcn CLI was set up, we can use the CLI instead to download all the additional dependencies.

https://ui.shadcn.com/docs/components/form

This is the link for setting up a form in Shadcn-ui.

pnpm dlx shadcn-ui@latest add form
pnpm dlx shadcn-ui@latest add toast
pnpm dlx shadcn-ui@latest add input
pnpm dlx shadcn-ui@latest add button
pnpm dlx shadcn-ui@latest add input
pnpm dlx shadcn-ui@latest add table

If the Shadcn CLI was set up correctly, this should add the UI components in the src → components → ui path.

Go to the path where each of the components was added, and double-check if there are any missing dependencies. In the toast.tsx file, there might be a missing dependency, it’ll be highlighted below.

Add the dependency manually.

pnpm add @radix-ui/react-toast

Import the following components into the signin.tsx file in the pages directory for Shadcn forms components, it uses react-hook-forms under the hood.

import * as z from "zod";
import { useForm, Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "../components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "../components/ui/form";
import { Input } from "../components/ui/input";
import { useToast } from "../components/ui/use-toast";
import { Toaster } from "../components/ui/toaster";

Adding the Supabase Components

Add in the Supabase components below.

pnpm add @supabase/auth-helpers-react

Putting it together

Set up the form schema below, and define the maximum or minimum inputs contingent on your project needs.

const formSchema = z.object({
  email: z.string().min(2).max(50),
  password: z.string().min(2),
});

Import the Supabase library component.

import { useSupabaseClient } from "@supabase/auth-helpers-react";

Create a React component that will be exported, the formSchema constant is outside of this function. Add in the Supabase library components and have it integrate with the react-forms-hook component.

function SignIn() {
  const supabase = useSupabaseClient();
  const router = useRouter();
  const { toast } = useToast();
  const [isSignUp, setIsSignUp] = useState(false);

  const signInWithPassword = async (email: string, password: string) => {
    const { error, data } = isSignUp
      ? await supabase.auth.signUp({
          email,
          password,
        })
      : await supabase.auth.signInWithPassword({
          email,
          password,
        });
    if (error) {
      toast({
        variant: "destructive",
        title: "Something went wrong",
        description: "Error with auth" + error.message,
      });
    } else if (isSignUp && data.user) {
      setIsSignUp(false);
    } else if (data.user) {
      router.push("/app/overview").catch((err) => {
        console.error("Failed to navigate", err);
      });
    }
  };

  const signOut = async () => {
    const { error } = await supabase.auth.signOut();
    if (error) {
      console.error("Error signing out:", error.message);
    } else {
      toast({
        variant: "default",
        title: "Signed out successfully",
      });
      router.push("/signin").catch((err) => {
        console.error("Failed to navigate", err);
      });
    }
  };

  // 1. Define your form.
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  // 2. Define a submit handler.
  function onSubmit(values: z.infer<typeof formSchema>) {
    // Do something with the form values.
    // ✅ This will be type-safe and validated.
    signInWithPassword(values.email, values.password).catch((err) => {
      console.error(err);
    });
  }

  return (
    <div className="flex min-h-screen items-center justify-center">
      <Toaster></Toaster>
      <div className="w-96">
        <div className="flex flex-col items-start">
          <Form {...form}>
            <h1 className="mb-4 text-3xl font-bold">T3 Demo Sign-In</h1>

            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
              <FormField
                control={form.control}
                name="email"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Email</FormLabel>
                    <FormControl>
                      <Input className="w-96" placeholder="email" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="password"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Password</FormLabel>
                    <FormControl>
                      <Input
                        className="w-96"
                        placeholder="password"
                        {...field}
                      />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <div className="flex justify-end">
                <Button type="submit">Login</Button>
                <Button onClick={signOut}>Sign Out</Button>
                <Button onClick={() => setIsSignUp((s) => !s)}>
                  {isSignUp
                    ? "Already have an account?"
                    : "Don't have an account?"}
                </Button>
              </div>
            </form>
          </Form>
        </div>
      </div>
    </div>
  );
}

export default SignIn;

signup.tsx

The signup.tsx should be nearly the same, the only difference being the signInWithPassword gets replaced with signUpWithPassword and the onSubmit function uses signUpWithPassword instead of signInWithPassword

const signUpWithPassword = async (email: string, password: string) => {
    const { error, data } = await supabase.auth.signUp({
      email,
      password,
    });

    if (error) {
      toast({
        variant: "destructive",
        title: "Something went wrong",
        description: "Error with auth" + error.message,
      });
    } else if (data.user) {
      toast({
        variant: "default",
        title: "Check your email",
      });
      router.push("/signin").catch((err) => {
        console.error("Failed to navigate", err);
      });
    }
  };
function onSubmit(values: z.infer<typeof formSchema>) {
    signUpWithPassword(values.email, values.password).catch((err) => {
      console.error(err);
    });
  }

Modifying the T3 Scaffold

env.mjs

Below is the default env.mjs setup that is located in src → env.mjs directory

import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  /**
   * Specify your server-side environment variables schema here. This way you can ensure the app
   * isn't built with invalid env vars.
   */
  server: {
    DATABASE_URL: z.string().url(),
    NODE_ENV: z.enum(["development", "test", "production"]),
    NEXTAUTH_SECRET:
      process.env.NODE_ENV === "production"
        ? z.string().min(1)
        : z.string().min(1).optional(),
    NEXTAUTH_URL: z.preprocess(
      // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
      // Since NextAuth.js automatically uses the VERCEL_URL if present.
      (str) => process.env.VERCEL_URL ?? str,
      // VERCEL_URL doesn't include `https` so it cant be validated as a URL
      process.env.VERCEL ? z.string().min(1) : z.string().url()
    ),
    // Add `.min(1) on ID and SECRET if you want to make sure they're not empty
    DISCORD_CLIENT_ID: z.string(),
    DISCORD_CLIENT_SECRET: z.string(),
  },

  /**
   * Specify your client-side environment variables schema here. This way you can ensure the app
   * isn't built with invalid env vars. To expose them to the client, prefix them with
   * `NEXT_PUBLIC_`.
   */
  client: {
    // NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
  },

  /**
   * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
   * middlewares) or client-side so we need to destruct manually.
   */
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    NODE_ENV: process.env.NODE_ENV,
    NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
    NEXTAUTH_URL: process.env.NEXTAUTH_URL,
    DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
    DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
  },
  /**
   * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
   * This is especially useful for Docker builds.
   */
  skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});

We’re going to remove all the NEXTAUTH and discord references as we’re not using discord authentication in this scaffold.

import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  /**
   * Specify your server-side environment variables schema here. This way you can ensure the app
   * isn't built with invalid env vars.
   */
  server: {
    DATABASE_URL: z.string().url(),
    NODE_ENV: z.enum(["development", "test", "production"]),
  },

  /**
   * Specify your client-side environment variables schema here. This way you can ensure the app
   * isn't built with invalid env vars. To expose them to the client, prefix them with
   * `NEXT_PUBLIC_`.
   */
  client: {
    NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),
    NEXT_PUBLIC_ANON_KEY: z.string().min(1),
  },

  /**
   * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
   * middlewares) or client-side so we need to destruct manually.
   */
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    NODE_ENV: process.env.NODE_ENV,
    NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
    NEXT_PUBLIC_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
  },
  /**
   * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
   * This is especially useful for Docker builds.
   */
  skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});

This is what we’re left with after the appropriate modifications.

src → pages → api → auth

We can delete the auth folder under API since we’re using Supabase auth instead.

Modify the trpc.ts file in src/server path

Below is the default, base configuration in the src → server → api → trpc.ts path.

import { initTRPC, TRPCError } from "@trpc/server";
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
import { type Session } from "next-auth";
import superjson from "superjson";
import { ZodError } from "zod";
import { getServerAuthSession } from "~/server/auth";
import { prisma } from "~/server/db";

/**
 * 1. CONTEXT
 *
 * This section defines the "contexts" that are available in the backend API.
 *
 * These allow you to access things when processing a request, like the database, the session, etc.
 */

interface CreateContextOptions {
  session: Session | null;
}

/**
 * This helper generates the "internals" for a tRPC context. If you need to use it, you can export
 * it from here.
 *
 * Examples of things you may need it for:
 * - testing, so we don't have to mock Next.js' req/res
 * - tRPC's `createSSGHelpers`, where we don't have req/res
 *
 * @see <https://create.t3.gg/en/usage/trpc#-serverapitrpcts>
 */
const createInnerTRPCContext = (opts: CreateContextOptions) => {
  return {
    session: opts.session,
    prisma,
  };
};

/**
 * This is the actual context you will use in your router. It will be used to process every request
 * that goes through your tRPC endpoint.
 *
 * @see <https://trpc.io/docs/context>
 */
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
  const { req, res } = opts;

  // Get the session from the server using the getServerSession wrapper function
  const session = await getServerAuthSession({ req, res });

  return createInnerTRPCContext({
    session,
  });
};

/**
 * 2. INITIALIZATION
 *
 * This is where the tRPC API is initialized, connecting the context and transformer. We also parse
 * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
 * errors on the backend.
 */

const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

/**
 * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
 *
 * These are the pieces you use to build your tRPC API. You should import these a lot in the
 * "/src/server/api/routers" directory.
 */

/**
 * This is how you create new routers and sub-routers in your tRPC API.
 *
 * @see <https://trpc.io/docs/router>
 */
export const createTRPCRouter = t.router;

/**
 * Public (unauthenticated) procedure
 *
 * This is the base piece you use to build new queries and mutations on your tRPC API. It does not
 * guarantee that a user querying is authorized, but you can still access user session data if they
 * are logged in.
 */
export const publicProcedure = t.procedure;

/** Reusable middleware that enforces users are logged in before running the procedure. */
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      // infers the `session` as non-nullable
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

/**
 * Protected (authenticated) procedure
 *
 * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
 * the session is valid and guarantees `ctx.session.user` is not null.
 *
 * @see <https://trpc.io/docs/procedures>
 */
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

We’ll need to import some types and functions from the supabase/auth-helpers-nextjs library that should already be installed from the signin.tsx code.

import {
  createPagesServerClient,
  type User,
} from "@supabase/auth-helpers-nextjs";

Change the interface from Session that maps to Nextauth.js to User which comes from Supabase.

interface CreateContextOptions {
  session: User | null;
}

The innerTRPCContext also needs to be changed from session to user .

const createInnerTRPCContext = (opts: CreateContextOptions) => {
  return {
    session: opts.session,
    prisma,
  };
};
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
  return {
    **user: opts.user,**
    prisma,
  };
};

Next, the createTRPCContext also needs to be changed to communicate with Supabase, nearly all the code is replaced.

export const createTRPCContext = async (opts: CreateNextContextOptions) => {
  const { req, res } = opts;

  // Get the session from the server using the getServerSession wrapper function
  const session = await getServerAuthSession({ req, res });

  return createInnerTRPCContext({
    session,
  });
};
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
  const supabase = createPagesServerClient(opts);

  // browsers will have the session cookie set
  const token = opts.req.headers.authorization;

  const user = token
    ? await supabase.auth.getUser(token)
    : await supabase.auth.getUser();

  return createInnerTRPCContext({
    user: user.data.user,
  });
};

The enforcedUserIsAuthed variable needs to be changed, below is the default to use the user properties instead

/** Reusable middleware that enforces users are logged in before running the procedure. */
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      // infers the `session` as non-nullable
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user?.id) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      // infers the `user` as non-nullable
      user: ctx.user,
    },
  });
});

Modify the _app.tsx file

Below is the base configuration.

import { type Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { type AppType } from "next/app";
import { api } from "~/utils/api";
import "~/styles/globals.css";

const MyApp: AppType<{ session: Session | null }> = ({
  Component,
  pageProps: { session, ...pageProps },
}) => {
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />
    </SessionProvider>
  );
};

export default api.withTRPC(MyApp);

We have to import the supabase/auth-helpers-react and supabase/auth-helpers-nextjs libraries.

import {
  createPagesBrowserClient,
  type Session,
} from "@supabase/auth-helpers-nextjs";
import { SessionContextProvider } from "@supabase/auth-helpers-react";

Change from AppType to AppProps and user the SessionContextProvider from the supabase/auth-helpers-nextjs library.

function MyApp({
  Component,
  pageProps,
}: **AppProps**<{ initialSession: Session | null }>) {
  const [supabaseClient] = useState(() => createPagesBrowserClient());

  return (
    <SessionContextProvider
      supabaseClient={supabaseClient}
      initialSession={pageProps.initialSession}
    >
      <Component {...pageProps} />
    </SessionContextProvider>
  );
}

Schema.Prisma File

For the schema.prisma file we’re going to be using a single-schema db, as was mentioned at the beginning of the article that there are some challenges with a multi-schema db as it’s still a preview feature in Prisma.

Below is the default configuration, we’re going to change the provider to postgresql from sqlite .

generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "sqlite"
    // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
    // Further reading:
    // <https://next-auth.js.org/adapters/prisma#create-the-prisma-schema>
    // <https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string>
    url      = env("DATABASE_URL")
}

We added three tables, only the users table is essential but the other two will be used as examples.

generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "postgresql"
    url      = env("DATABASE_URL")
}

/// This model contains row level security and requires additional setup for migrations. Visit <https://pris.ly/d/row-level-security> for more info.
model users {
    user_id        String           @id @db.Uuid
    updated_at     DateTime?        @db.Timestamptz(6)
    privateExample privateExample[]
}

model example {
    id          String    @id @default(uuid()) @db.Uuid
    firstEntry  String?
    lastUpdated DateTime? @updatedAt @db.Timestamptz(6)
}

model privateExample {
    id          String    @id @default(uuid()) @db.Uuid
    user_id     String    @db.Uuid
    users       users     @relation(fields: [user_id], references: [user_id])
    firstEntry  String?
    lastUpdated DateTime? @updatedAt @db.Timestamptz(6)
}

The prisma.schema file above is for a single-schema db, all the tables above are in the “public” schema. The public.users table is mirroring the id property from the auth.users table, being copied over. The example table is like a public newsfeed and the privateExample is for private user content, hence the one-to-many relationship from the users table to the privateExample table.

tester.tsx page

On the tester.tsx page, we’re going to query the example and privateExample table and also add entries to it using an input area. This will show you to add data to a table both publicly and from an authenticated user.

Create New Route src → server → api → routers → firstRouter.ts

Import zod, createTRPCRouter, protectedProcedure and publicProcedure

import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";

Create Four Methods, getAll, create, getAllPrivate, createPrivate

The getAll is available publicly by any user, the create is for public upload, getAllPrivate is for all individual user contributions and createPrivate is for individual user upload.

export const firstRouter = createTRPCRouter({
  getAll: publicProcedure.query(({ ctx }) => {
    return ctx.prisma.example.findMany();
  }),

  create: publicProcedure
    .input(z.object({ firstEntry: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.example.create({
        data: {
          firstEntry: input.firstEntry,
        },
      });
    }),

  getAllPrivate: protectedProcedure.query(({ ctx }) => {
    return ctx.prisma.privateExample.findMany({
      where: {
        user_id: ctx.user.id,
      },
    });
  }),

  createPrivate: protectedProcedure
    .input(z.object({ user_id: z.string(), firstEntry: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.privateExample.create({
        data: {
          firstEntry: input.firstEntry,
          user_id: input.user_id,
        },
      });
    }),
});

Update root.ts in src → server → api → routers

First, import the new router in the root.ts file then add it in the export statement.

import { exampleRouter } from "~/server/api/routers/example";
import { createTRPCRouter } from "~/server/api/trpc";
**import { firstRouter } from "./routers/firstRouter";**

/**
 * This is the primary router for your server.
 *
 * All routers added in /api/routers should be manually added here.
 */
export const appRouter = createTRPCRouter({
  example: exampleRouter,
  **first: firstRouter,**
});

// export type definition of API
export type AppRouter = typeof appRouter;

Client-Side Code tester.tsx

Import the Shadcn-UI components.

import { Input } from "../components/ui/input";
import {
  Table,
  TableCaption,
  TableHeader,
  TableRow,
  TableHead,
  TableCell,
  TableBody,
} from "../components/ui/table";
import { Button } from "../components/ui/button";

Import React library components.

import { api } from "../utils/api";
import { useState } from "react";
import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react";
import { Input } from "../../components/ui/input";

Put it all together and you should get two columns, one for the public user data and the other for private user data.

export default function Content() {
  const { data } = api.example.getSecretMessage.useQuery();
  const { data: items, refetch: refetchItems } = api.first.getAll.useQuery();
  const { data: privateItems, refetch: refetchPrivateItems } =
    api.first.getAllPrivate.useQuery();

  const [inputValue, setInputValue] = useState("");
  const [inputPrivateValue, setInputPrivateValue] = useState("");

  const user = useUser();

  const createExample = api.first.create.useMutation({
    onSuccess: () => {
      setInputValue("");
      void refetchItems();
    },
  });

  const createPrivateExample = api.first.createPrivate.useMutation({
    onSuccess: () => {
      setInputPrivateValue("");
      void refetchPrivateItems();
    },
  });

  const handleChange = (e) => {
    setInputValue(e.target.value);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    createExample.mutate({ firstEntry: inputValue });
  };

  const handleChangePrivate = (e) => {
    setInputPrivateValue(e.target.value);
  };

  const handlePrivateSubmit = (e) => {
    e.preventDefault();
    if (!!user) {
      createPrivateExample.mutate({
        firstEntry: inputPrivateValue,
        user_id: user.id,
      });
    }
  };

const signOut = async () => {
    const { error } = await supabase.auth.signOut();
    if (error) {
      console.error("Error signing out:", error.message);
    } else {
      router.push("/signin").catch((err) => {
        console.error("Failed to navigate", err);
      });
    }
  };

  return (
    <div className="mx-auto max-w-7xl space-y-3">
      <div className="space-y-2">
        <h1 className="text-3xl"> Example </h1>
        <h1>user: {user?.id}</h1>
        <h1>{data}</h1>
        <Button onClick={signOut}>Sign Out</Button>
      </div>

      <div className="space-y-1">
        <h1 className="text-xl">Input Box</h1>
        <form onSubmit={handleSubmit} className="flex space-x-4">
          <Input
            type="text"
            value={inputValue}
            onChange={handleChange}
            placeholder="Enter a string"
            className="flex-grow"
          />
          <Button type="submit">Submit</Button>
        </form>

        <h1 className="text-xl">Private Input Box</h1>
        <form onSubmit={handlePrivateSubmit} className="flex space-x-4">
          <Input
            type="text"
            value={inputPrivateValue}
            onChange={handleChangePrivate}
            placeholder="Enter a private string"
            className="flex-grow"
          />
          <Button type="submit">Submit</Button>
        </form>
      </div>

      <div className="mt-8 grid grid-cols-2 gap-4">
        {items && (
          <Table>
            <TableCaption>Items:</TableCaption>
            <TableHeader>
              <TableRow>
                <TableHead>First Entry</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {items.map((item, index) => (
                <TableRow key={index}>
                  <TableCell>{item.firstEntry}</TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}

        {privateItems && (
          <Table>
            <TableCaption>Private Items:</TableCaption>
            <TableHeader>
              <TableRow>
                <TableHead>First Entry</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {privateItems.map((item, index) => (
                <TableRow key={index}>
                  <TableCell>{item.firstEntry}</TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </div>
    </div>
  );
}

You should get a screenshot that looks like this

Middleware Function

We’re also going to create a middleware function to protect routes in your web app. First, we’re going to create a new folder in the pages directory called protected located in src → pages → protected. From here we’re going to move the tester.tsx page to be nested inside this folder.

Using the code from this page below in the Supabase docs, we modify it for our needs.

https://supabase.com/docs/guides/auth/auth-helpers/nextjs-pages#auth-with-nextjs-middleware

Below is the reference middleware.ts code from Supabase.

import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(req: NextRequest) {
  // We need to create a response and hand it to the supabase client to be able to modify the response headers.
  const res = NextResponse.next()
  // Create authenticated Supabase Client.
  const supabase = createMiddlewareClient({ req, res })
  // Check if we have a session
  const {
    data: { session },
  } = await supabase.auth.getSession()

  // Check auth condition
  if (session?.user.email?.endsWith('@gmail.com')) {
    // Authentication successful, forward request to protected route.
    return res
  }

  // Auth condition not met, redirect to home page.
  const redirectUrl = req.nextUrl.clone()
  redirectUrl.pathname = '/'
  redirectUrl.searchParams.set(`redirectedFrom`, req.nextUrl.pathname)
  return NextResponse.redirect(redirectUrl)
}

export const config = {
  matcher: '/middleware-protected/:path*',
}

We’re going to change the matcher so that it protects everything in the protected directory leaving the “/” for a welcome or landing page accessible without authentication alongside the signin page and signup page. The middleware.ts file will get added to the src → middleware.ts directory. It’s imperative that the middleware function be named middleware.ts otherwise it won’t work.

export async function middleware(req: NextRequest) {
  // We need to create a response and hand it to the supabase client to be able to modify the response headers.
  const res = NextResponse.next();
  // Create authenticated Supabase Client.
  const supabase = createMiddlewareClient({ req, res });
  // Check if we have a session
  const {
    data: { session },
  } = await supabase.auth.getSession();

  // Check if user is on the signin page
  if (req.nextUrl.pathname === "/signin" || req.nextUrl.pathname === "/") {
    return res;
  }

  // Check auth condition
  if (session) {
    // Authentication successful, forward request to protected route.
    return res;
  }

  // Auth condition not met, redirect to home page.
  const redirectUrl = req.nextUrl.clone();
  redirectUrl.pathname = "/signin";
  redirectUrl.searchParams.set(`redirectedFrom`, req.nextUrl.pathname);
  return NextResponse.redirect(redirectUrl);
}

export const config = {
  matcher: "/protected/:path*",
};

Conclusion

If you followed the tutorial you should have a working t3 project using Supabase auth and db with Shadcn-UI all ready to go. If this article helped you out feel free to give me a follow on Twitter. If there are any errors in this article or any other questions, free free to comment below or reach out to me on Twitter, my DMs are open. Check out the GitHub link below to use the template if you get stuck or just want to get started. https://github.com/remusris/t3_supabase_demo

0
Subscribe to my newsletter

Read articles from Remus directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Remus
Remus