This blog will focus on securing our Remix application with Supabase's Row Level Security (RLS) feature.
If you want to know the context of what application I'm talking about, you can refer to my another blog.
Setting up Supabase
Instead of updating my database from the previous blog, I'm just going to re-create it.
Create a table to contain user_id
sql
CREATETABLE words ( id bigint GENERATED BYDEFAULTASIDENTITYPRIMARYKEY, name varcharNOTNULL, definitions varchar ARRAY NOTNULL, sentences varchar ARRAY NOTNULL,typevarcharNOTNULL, user_id uuid NOTNULL);
Add a foreign key in user_id pointing to auth.users
Use createCookieSessionStorage to help in managing our Supabase token
app/utils/supabase.server.tsx
1234567891011121314151617181920212223// ...import{ createCookieSessionStorage }from"remix";// ...const{ getSession, commitSession, destroySession }=createCookieSessionStorage({// a Cookie from `createCookie` or the CookieOptions to create one cookie:{ name:"supabase-session",// all of these are optional expires:newDate(Date.now()+3600), httpOnly:true, maxAge:60, path:"/", sameSite:"lax", secrets:["s3cret1"], secure:true,},});export{ getSession, commitSession, destroySession };
Create a utility to set the Supabase token from the Request
Since I'm too lazy to implement a login page, I'll just use the UI provided by Supabase.
Install @supabase/ui
sh
npm install @supabase/uiyarn add @supabase/ui
Create the main auth component
You can create your custom sign-up and sign-in form if you want.
app/routes/auth.tsx
123456789101112131415importReactfrom"react";import{Auth}from"@supabase/ui";import{ useSupabase }from"~/utils/supabase-client";exportdefaultfunctionAuthBasic(){const supabase =useSupabase();return(<Auth.UserContextProvidersupabaseClient={supabase}><Container>{/* TODO */}<AuthsupabaseClient={supabase}/></Container></Auth.UserContextProvider>);}
Create the component to inform the server that we have a Supabase session
app/routes/auth.tsx
1234567891011121314151617181920212223242526importReact,{ useEffect }from"react";import{ useSubmit }from"remix";constContainer:React.FC=({ children })=>{const{ user, session }=Auth.useUser();const submit =useSubmit();useEffect(()=>{if(user){const formData =newFormData();const accessToken = session?.access_token;// you can choose whatever conditions you want// as long as it checks if the user is signed inif(accessToken){ formData.append("access_token", accessToken);submit(formData,{ method:"post", action:"/auth"});}}},[user]);return<>{children}</>;};// ...
Create an action handler to process the Supabase token
I don't want to pollute my other route, so I will create my signout action handler separately
app/routes/signout.tsx
123456789101112131415161718import{ destroySession, getSession }from"../utils/supabase.server";import{ redirect }from"remix";importtype{ActionFunction}from"remix";exportconst action:ActionFunction=async({ request })=>{let session =awaitgetSession(request.headers.get("Cookie"));returnredirect("/auth",{ headers:{"Set-Cookie":awaitdestroySession(session),},});};exportconstloader=()=>{// Redirect to `/` if user tried to access `/signout`returnredirect("/");};
TL;DR version of using our setup
Using in a loader or action
.tsx
123456789exportconstaction=async({ request, params })=>{// Just set the token to any part you want to have access to.// I haven't tried making a global handler for this,// but I prefer to be explicit about setting this.awaitsetAuthToken(request);await supabase.from("words").update(/*...*/);// ...};
NOTE: Conditional server-side rendering might cause hydration warning,
I'll fix this in another blog post.
Using in CRUD Operations
The examples below are a longer version of using our setup for CRUD operations.
Fetching All operation
app/routes/words.tsx
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364import{Form, useTransition }from"remix";importtype{LoaderFunction}from"remix";import{ useLoaderData,Link,Outlet}from"remix";import{Button}from"~/components/basic/button";import{ supabase }from"~/utils/supabase.server";importtype{Word}from"~/models/word";import{ useSupabase }from"~/utils/supabase-client";exportconst loader:LoaderFunction=async()=>{// No need to add auth here, because GET /words is publicconst{ data: words }=await supabase.from<Word>("words").select("id,name,type");// We can pick and choose what we want to display// This can solve the issue of over-fetching or under-fetchingreturn words;};exportdefaultfunctionIndex(){const words =useLoaderData<Word[]>();const transition =useTransition();const supabase =useSupabase();return(<mainclassName="p-2"><h1className="text-3xl text-center mb-3">English words I learned</h1><divclassName="text-center mb-2">Route State: {transition.state}</div><divclassName="grid grid-cols-1 md:grid-cols-2 "><divclassName="flex flex-col items-center"><h2className="text-2xl pb-2">Words</h2><ul>{words.map((word)=>(<likey={word.id}><Linkto={`/words/${word.id}`}>{word.name} | {word.type}</Link></li>))}</ul>{/* Adding conditional rendering might cause a warning, We'll deal with it later */}{supabase.auth.user()?(<Formmethod="get"action={"/words/add"}className="pt-2"><Buttontype="submit"className="hover:bg-primary-100 dark:hover:bg-primary-900"> Add new word </Button></Form> ) : (<Formmethod="get"action={`/auth`}className="flex"><Buttontype="submit"color="primary"className="w-full"> Sign-in to make changes</Button></Form> )}</div><Outlet/></div></main> );}
Retrieve one and Delete one operation
app/routes/words/$id.tsx
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677import{Form, useLoaderData, redirect, useTransition }from"remix";importtype{LoaderFunction,ActionFunction}from"remix";importtype{Word}from"~/models/word";import{Input}from"~/components/basic/input";import{Button}from"~/components/basic/button";import{ setAuthToken, supabase }from"~/utils/supabase.server";import{ useSupabase }from"~/utils/supabase-client";// Here's how to delete one entryexportconst action:ActionFunction=async({ request, params })=>{const formData =await request.formData();// Auth Related CodeawaitsetAuthToken(request);if(formData.get("_method")==="delete"){await supabase.from<Word>("words").delete().eq("id", params.idasstring);returnredirect("/words");}};// Here's the how to fetch one entryexportconst loader:LoaderFunction=async({ params })=>{// No need to add auth here, because GET /words is publicconst{ data }=await supabase.from<Word>("words").select("*").eq("id", params.idasstring).single();return data;};exportdefaultfunctionWord(){const word =useLoaderData<Word>();const supabase =useSupabase();let transition =useTransition();return(<div><h3>{word.name} | {word.type}</h3><div>Form State: {transition.state}</div>{word.definitions.map((definition, i)=>(<pkey={i}><i>{definition}</i></p>))}{word.sentences.map((sentence, i)=>(<pkey={i}>{sentence}</p>))}{/* Adding conditional rendering might cause a warning, We'll deal with it later */}{supabase.auth.user()&&(<><Formmethod="post"><Inputtype="hidden"name="_method"value="delete"/><Buttontype="submit"className="w-full"> Delete</Button></Form><Formmethod="get"action={`/words/edit/${word.id}`}className="flex"><Buttontype="submit"color="primary"className="w-full"> Edit</Button></Form></>)}</div>);}
123456789101112131415161718192021222324252627282930313233343536373839import{ useLoaderData, redirect }from"remix";importtype{LoaderFunction,ActionFunction}from"remix";import{WordForm}from"~/components/word-form";importtype{Word}from"~/models/word";import{ setAuthToken, supabase }from"~/utils/supabase.server";exportconst action:ActionFunction=async({ request, params })=>{const formData =await request.formData();const id = params.idasstring;const updates ={ type: formData.get("type"), sentences: formData.getAll("sentence"), definitions: formData.getAll("definition"),};// Auth Related CodeawaitsetAuthToken(request);await supabase.from("words").update(updates).eq("id", id);returnredirect(`/words/${id}`);};exportconst loader:LoaderFunction=async({ params })=>{const{ data }=await supabase.from<Word>("words").select("*").eq("id", params.idasstring).single();return data;};exportdefaultfunctionEditWord(){const data =useLoaderData<Word>();return<WordFormword={data}/>;}
Conclusion
We can still use Supabase only on the client-side as we use it on a typical React application.
However, putting the data fetching on the server-side will allow us to benefit from a typical SSR application.