The biggest announcement from Next.js Conf 2022 was the release of Next.js 13, which introduces a collection of improvements, most exciting of which is Server Components. The combination of Server Components and Suspense allow for a more streamlined, reimagined way to fetch and cache data in Next.js applications. This provides excellent DX improvements — such as async components — and aligns the Next framework even closer with the future of React.
This article is going to look at how we can use these brand new async components to simplify fetching and caching data from Supabase. We will look all things auth in a separate article. Check out the Server Components example in the Auth Helpers repo if you just can't wait!
To learn more about any of the concepts covered in this article, check out the Next.js beta docs.
A good distinction to understand at this point, is that Next.js 13 is stable and ready for production, however, the app directory is still in beta and likely to change. This article will be focusing on the app directory, Server Components and Suspense so let's get experimental!
For an example of the code covered in this tutorial, check out this repo.
If you prefer video, check out our recent live stream where we stepped through a similar example.
Let’s get started by creating a brand new Next.js 13 app using the create-next-app package:
_10npx create-next-app@latest --experimental-app next13
Now we can run our app in development mode:
_10npm run dev
And navigate to http://localhost:3000.
This should look pretty familiar, and scanning the folder structure for the app, it should look almost identical to Next.js 12, but with a new folder called app. This is where the new data fetching and caching magic takes place. 🪄
Each folder within the app directory represents a route in our application. Each folder must have a page component, which is rendered when the user navigates to the route, and optional layout, loading and error components.
Learn more about Page, Layout, Loading and Error components in the Next.js beta docs.
Before we jump into fetching data, we need some data to fetch. Let’s create a new Supabase project.
Once your instance is up and running, head over to the SQL Editor, paste in the following snippet and click RUN.
_11create table if not exists posts (_11 id uuid default gen_random_uuid() primary key,_11 created_at timestamp with time zone default timezone('utc'::text, now()) not null,_11 title text,_11 content text_11);_11_11insert into posts(title, content)_11values_11 ('My first post', 'Wow! What a great post.'),_11 ('My second post', 'This one needs a little work!');
This will create a table called posts, and populate it with some example data.
Let’s install the supabase-js library to fetch our posts.
_10npm install @supabase/supabase-js
And add a .env.local file with the following environment variables:
_10NEXT_PUBLIC_SUPABASE_URL=your-supabase-url_10NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
The values for these can be found in your project’s API settings.
Lastly, we need to create a Supabase client. Create a file at utils/supabase.ts with the following content:
_10import { createClient } from '@supabase/supabase-js'_10_10export default createClient(_10 process.env.NEXT_PUBLIC_SUPABASE_URL!,_10 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!_10)
For automatically adding types to the Supabase client, check out how to generate types.
Okay, let’s look at some different data fetching and caching strategies.
Static
By default any page component in the app folder is a Server Component, and its data is fetched and cached by Next.js every time we build a new version of our application. This is equivalent to exporting a getStaticProps function from a component in the pages directory.
Let’s create a new file at app/static/page.tsx and populate with the following:
_10import supabase from '../../utils/supabase'_10_10export default async function Posts() {_10 const { data: posts } = await supabase.from('posts').select()_10 return <pre>{JSON.stringify(posts, null, 2)}</pre>_10}
Server Components support async/await by default, and suspend the rendering of the component until the data has been fetched. This means we don’t need to handle error or loading states in our component, keeping our rendering logic clean.
To learn more about displaying
loadinganderrorstates, check out the documentation.
Let’s modify this component to render out a collection of <Link /> components, that navigate to a dedicated page for each post.
_17import Link from 'next/link'_17_17import supabase from '../../utils/supabase'_17_17export default async function Posts() {_17 const { data: posts } = await supabase.from('posts').select('id, title')_17_17 if (!posts) {_17 return <p>No posts found.</p>_17 }_17_17 return posts.map((post) => (_17 <p key={post.id}>_17 <Link href={`/static/${post.id}`}>{post.title}</Link>_17 </p>_17 ))_17}
Since we are only using
idandtitlein our component, we can scope our query down to only return these two columns for each post.
Let’s create a dynamic route to handle displaying an individual post. Create a new file at app/static/[id]/page.tsx and populate with the following:
_13import { notFound } from 'next/navigation'_13_13import supabase from '../../../utils/supabase'_13_13export default async function Post({ params: { id } }: { params: { id: string } }) {_13 const { data } = await supabase.from('posts').select().match({ id }).single()_13_13 if (!data) {_13 notFound()_13 }_13_13 return <pre>{JSON.stringify(data, null, 2)}</pre>_13}
Currently, this page is generated on-demand and then cached. This means the first person who visits the page will need to wait for the server to get the post data from Supabase. This won’t take long at all, because Supabase is Supa awesome! But, we can still make this slightly more efficient by telling Next.js a finite collection of paths that we want to generate at build time.
We do this by exporting out a generateStaticParams function from our dynamic page.
_10export async function generateStaticParams() {_10 const { data: posts } = await supabase.from('posts').select('id')_10_10 return posts?.map(({ id }) => ({_10 id,_10 }))_10}
This is similar to
getStaticPathsin a pages component. Learn more here.
The full component should look something like this:
_21import { notFound } from 'next/navigation'_21_21import supabase from '../../../utils/supabase'_21_21export async function generateStaticParams() {_21 const { data: posts } = await supabase.from('posts').select('id')_21_21 return posts?.map(({ id }) => ({_21 id,_21 }))_21}_21_21export default async function Post({ params: { id } }: { params: { id: string } }) {_21 const { data: post } = await supabase.from('posts').select().match({ id }).single()_21_21 if (!post) {_21 notFound()_21 }_21_21 return <pre>{JSON.stringify(post, null, 2)}</pre>_21}
Awesome! We now have a Supa snappy blog! The user never needs to wait for data to be fetched. All pages are statically generated at build time, and cached at CDN nodes close to our users! 🎉
Unfortunately, this means any changes we make in Supabase — adding, updating or deleting posts etc — will not be reflected in our blog. If we want to refresh this data on a regular basis, we need to tell Next.js when to revalidate.
Static with Revalidation
By exporting a revalidate variable from our component, we can specify how many seconds we consider this data to be “fresh”.
_10export const revalidate = 60
This is similar to returning a
revalidatekey from thegetStaticPropsfunction in a component from thepagesdirectory.
So, for 60 seconds Next.js will continue to respond with the static version of our page. After 60 seconds, it will fetch fresh data from Supabase and generate a new static page. However, there is no downtime while this happens, as the previous static page will continue to be served until the “fresh” one has been successfully generated.
The Posts component should now look like this:
_19import Link from 'next/link'_19_19import supabase from '../../utils/supabase'_19_19export const revalidate = 60_19_19export default async function Posts() {_19 const { data: posts } = await supabase.from('posts').select('id, title')_19_19 if (!posts) {_19 return <p>No posts found.</p>_19 }_19_19 return posts.map((post) => (_19 <p key={post.id}>_19 <Link href={`/static/${post.id}`}>{post.title}</Link>_19 </p>_19 ))_19}
And the Post component should look like this:
_23import { notFound } from 'next/navigation'_23_23import supabase from '../../../utils/supabase'_23_23export const revalidate = 60_23_23export async function generateStaticParams() {_23 const { data: posts } = await supabase.from('posts').select('id')_23_23 return posts?.map(({ id }) => ({_23 id,_23 }))_23}_23_23export default async function Post({ params: { id } }: { params: { id: string } }) {_23 const { data: post } = await supabase.from('posts').select().match({ id }).single()_23_23 if (!post) {_23 notFound()_23 }_23_23 return <pre>{JSON.stringify(post, null, 2)}</pre>_23}
We now get all the benefits of static — users not waiting around while data is fetched at request time — but we also get the benefits of dynamic data, as it is being refreshed on a regular basis.
Very cool! 😎
Dynamic
If we want fresh data to be fetched on every single request, we can simply set our revalidate value to 0.
Posts Component
_19import Link from 'next/link'_19_19import supabase from '../../utils/supabase'_19_19export const revalidate = 0_19_19export default async function Posts() {_19 const { data: posts } = await supabase.from('posts').select('id, title')_19_19 if (!posts) {_19 return <p>No posts found.</p>_19 }_19_19 return posts.map((post) => (_19 <p key={post.id}>_19 <Link href={`/static/${post.id}`}>{post.title}</Link>_19 </p>_19 ))_19}
Single Post Component
_23import { notFound } from 'next/navigation'_23_23import supabase from '../../../utils/supabase'_23_23export const revalidate = 0_23_23export async function generateStaticParams() {_23 const { data: posts } = await supabase.from('posts').select('id')_23_23 return posts?.map(({ id }) => ({_23 id,_23 }))_23}_23_23export default async function Post({ params: { id } }: { params: { id: string } }) {_23 const { data: post } = await supabase.from('posts').select().match({ id }).single()_23_23 if (!post) {_23 notFound()_23 }_23_23 return <pre>{JSON.stringify(post, null, 2)}</pre>_23}
This is similar to exporting a getServerSideProps function from a component in the pages directory.
All this server stuff is great, but what if you want to use Supabase client-side? 🤔
Client-side
There are many use-cases where you need to use Supabase client-side:
-
Authentication
Supabase Auth does a bunch of stuff behind the scenes — handling 3rd party OAuth flows, for example. This will break if you try to sign users in and out on the server.
-
Realtime
Supabase manages the awesome power of websockets on your behalf — something that is not yet solved in this serverless world.
-
You prefer it
There is nothing wrong with this! You do you!
To use Supabase client-side, we need to tell Next.js that this is a Client Component. We do this by specifying the use client directive at the top of our component. This opts into a similar flow to the pages directory — the component is rendered on the server and hydrated client-side.
The React team is working on an awesome new hook called "use", which will drastically simplify fetching data client-side, but for now, we still need to rely on the combination of
useStateanduseEffect.
Let’s implement client-side data fetching.
_22'use client'_22_22import { useEffect, useState } from 'react'_22_22import supabase from '../../utils/supabase'_22_22export default function ClientPosts() {_22 const [isLoading, setIsLoading] = useState(true)_22 const [posts, setPosts] = useState<any>([])_22_22 useEffect(() => {_22 const fetchPosts = async () => {_22 const { data } = await supabase.from('posts').select()_22 setPosts(data)_22 setIsLoading(false)_22 }_22_22 fetchPosts()_22 }, [])_22_22 return isLoading ? <p>Loading</p> : <pre>{JSON.stringify(posts, null, 2)}</pre>_22}
Again, check out how to generate types to add proper typing for Post.
But now we have loading spinners! Yuck!
Realtime
Realtime allows us to subscribe to changes in Supabase — inserted, updated or deleted posts — and update our UI dynamically. In order to receive realtime events, we need to enable replication on the posts table.
Let’s merge the two previous concepts and fetch the initial state of our posts in a Server Component, and then render a Client Component to do client-y things — like subscribe to changes in the DB and update the UI dynamically:
Server Component
_10import supabase from '../../utils/supabase'_10import RealtimePosts from './realtime-posts'_10_10export const revalidate = 0_10_10export default async function Realtime() {_10 const { data } = await supabase.from('posts').select('*')_10 return <RealtimePosts serverPosts={data} />_10}
Client Component
_28'use client'_28_28import { useEffect, useState } from 'react'_28_28import supabase from '../../utils/supabase'_28_28export default function RealtimePosts({ serverPosts }: { serverPosts: any }) {_28 const [posts, setPosts] = useState(serverPosts)_28_28 useEffect(() => {_28 setPosts(serverPosts)_28 }, [serverPosts])_28_28 useEffect(() => {_28 const channel = supabase_28 .channel('*')_28 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, (payload) =>_28 setPosts((posts: any) => [...posts, payload.new])_28 )_28 .subscribe()_28_28 return () => {_28 supabase.removeChannel(channel)_28 }_28 }, [serverPosts])_28_28 return <pre>{JSON.stringify(posts, null, 2)}</pre>_28}
useEffectis used to subscribe to changes to theserverPostsprop. Without this, our component would not display fresh server-side results when the parent component is re-rendered, only on the first render.
This is a great pattern for fetching initial data server-side and subscribing to realtime changes client-side. This will likely be replaced by the use hook once it is stable with Next.js — it also uses suspense to suspend the rendering of a component while fetching data, and cleans up those loading and error states.
Conclusion
Next.js 13 Server Components are awesome! Suspense is awesome! Async components are awesome!
The combination of these concepts allow us to think about data fetching and caching as separate concerns, rather than specifying completely different data fetching functions like getStaticProps and getServerSideProps. If our caching requirements for a component change, we simply update the caching value, rather than refactoring our data fetching logic.
Additionally, by allowing any component in the tree to be either a server or client component — that is responsible for its own data and suspends rendering until it is ready — drastically simplifies our code, and provides much more flexible patterns for creating maintainable applications as complexity grows.
