Back

Everything I know about using Supabase with NextJS

I've started (and stopped) so many side projects over the years. Many stall-out just because I simply run into too many hurdles along the way. There's only so many nights, for example, I can spend trying to write custom endpoints in PHP before my motivation starts to wane. I'm a frontender afterall.

On my latest project, with Supabase at my side, I haven't encountered any of these motivation killing issues. None. Every evening, I just sit down and make incremental progress on a feature.

Sure, I still need to figure out how I'm going to structure data or implement something in the data layer, but all of the hard work is just that — thinking. When I've actually thought through how I'm going to implement something it's just a matter of writing the logic and queries. Supabase stays completely out of my way. It's an incredible shift from previous projects using tools like Node/Express, Rails, MongoDB and even the adjacent Next + Firebase stack.

If you are a frontender and are struggling with backend technologies, you simply need to try Supabase. With just a few nights of acclimatizing to the specifics, it's like you gain a new superpower.

Here's everything I wish I'd known about Supabase 2 months ago.

1. Auth

Auth with Supabase is both amazing and confusing.

If I've had any hurdle at all with Supebase it's been around Auth. My project has implemented Magic Link login And while it only took me about five minutes to get set up, there was actually much more to the whole set up with NextJS. There was cookies and sessions and server-side vs client-side, and all kinds of other things that I don't understand well. This video from Nader Dabit covers auth really well, but that's not how I ended up implementing auth on my project.

In the end, I opted to use the Auth component from Supabase's UI library and do thing client-side, thusly:

import { Auth } from "@supabase/ui";

export default function Page() {
	const { user } = Auth.useUser();
	return (<div></div>)
}

After much searching, this was the best approach I found. Many of the tutorials out there do things differently (even Supabase's own), and there doesn't seem to be a Right Way to do this.

So auth was a bit clunky, but using this approach, it's working great for me so far. My wish for the Supabase docs team would be consolidate all of the different approaches out there into a concise, regularly updated guide.

2. Maintain a schema.sql file

I only started this about halfway through, after looking at a bunch of different repos and regularly seeing conspicuous schema.sql files. I still use the gui a lot for quickly adding columns or changing data values to test my UI, But now every time I manually add a column in Supabase, I add a corresponding entry in schema.sql file.

In practice, this means that I'll add a new column for postal_code in the GUI, for example. I'll make sure to turn around and add postal_code text in my schema.sql file. This file can be used for things like Prisma, but I'm not actually using that. It's just a way to keep my configuration in code for easy setup.

3. Queries

Querying data in Supabase is amazing. The API is super clear and straightforward, and the docs are pretty good. You can do things like .in(), .or(), .eq(), .lt(), .lte() etc. It's great.

I say that they're pretty good because they're still a little light. I found that I could do basic queries via the docs, but often had to do some extra searching for asking in discord when I needed to do something more complex. An excellent source for these sorts of questions is the project's GitHub discussions. Ideally a lot of the problems that get solved here would make its way back to the official docs, but of course there's only so many hours in the day. Most discussions here that I've seen get great answers, and fast. Huge kudos to the team there.

One hurdle that wasn't obvious to me first was constructing complex, conditional queries.

Imagine I need to query for users who live in Toronto:

let { data } = await supabase
	.from("people")
	.select("*")
	.eq('city', 'Toronto');

Next, I want to filter people in Toronto, who play bingo:

let { data } = await supabase
	.from("people")
	.select("*")
	.eq('city', 'Toronto')
	.in('activities', 'bingo');

But what If I don't have any activities filtered yet:

let { data } = await supabase
	.from("people")
	.select("*")
	.eq('city', 'Toronto')
	.in('activities', []);

This won't return any data because activities is an empty array.

Instead, I need to conditionally add the .in('activities', [...]) if I have items in activities.

You can do that by constructing a query. Like so:

let query = supabase.from("people").select("*").eq("city", "Toronto");

	if (activities.length > 0) {
		query = query.in('activity', [activities]);
	}

let { data, error, status, count } = await query;

4. Counting results

Counting results is dead-simple.

You can return count as part of a query if you're already returning data like this:

let { data, count } = await supabase
	.from("users")
	.select("*", { count: "exact"})
	.eq("city", "Toronto");

Or, if you just want the actual count, without returning any data, you can just pass head: true to the select:

let { count } = await supabase
	.from("users")
	.select("*", { count: "exact", head: true })
	.eq("city", "Toronto");

5. Putting it all together

Finally, let's put this together a little more in the context of NextJS.

On most pages, I want to fetch some initial data server-side to avoid a flash of un-fetched content, then allow filtering to fetch new data client-side.

// File: /pages/people.js

export default function People({initialPeople, count}) {

	const [people, setPeople] = useState(initialPeople)
	const [count, setCount] = useState(count)
	const [filters, setFilters] = useState([])

	// Just get people from our filters object
	function fetchData() {
		let { data, count } = await supabase
		.from("people")
		.select("*")
		.eq('city', 'Toronto');

		// Update our state
		setPeople(data);
	}

	// fetch new data whenever filters state changes
	useEffect(() => {
		fetchData()
	}, [filters])

	return(<div>{people.map(...)</div>)
}

export async function getServerSideProps() {
	let { data, count } = await supabase
		.from("people")
		.select("*")

	return {
		props: {
			initialPeople: data,
			count,
		},
	};
}

Obviously a lot of pseudo-code there, but that's the gist: fetch server-side, then re-fetch later on when things change.

That's most of what I've been doing with Supabase so far. Fetching, querying, updating, and changing table structures. It's been extremely productive and a lot of fun. Until Remix changes my mind, the NextJS + Supabase combo is extremely compelling.

If your side projects have been crushed by the weight of backend overhead like mine, you owe it to yourself to try Supabase.

Follow along →@saltcod