Next.js Supabase Turbo
/

Learn how to build a SaaS with Next.js and Supabase

Server Components

Learn how to fetch data from Server Components in your Makerkit Next.js Supabase Turbo project.

Reading Time: 37 minutes

Hi there! 👋 Welcome to this module.

We will learn how to set up routing, build components, fetch data, and write data in your Makerkit Next.js Supabase Turbo project. In short, everything you need to know to build new features in your Makerkit Next.js Supabase Turbo project.

In the previous section, we learned and set up the project Database, and set up the data model. In this section, we will use the data model to fetch data and write data to the database.

In this section, we will be doing the following:

  • Support Tickets List: Create a new page for team accounts where support agents can view new support tickets.

We will be building and learning the following in this section:

  1. Routing: Setting up routing in Next.js, and creating new pages. We focus a little bit on Next.js Layouts and Pages, so you can follow along.
  2. Querying Supabase: Fetching data from the database using Supabase in Next.js.
  3. Server Components: Rendering data using React Server Components in Next.js
  4. Client Components: How and why we switch to Client Components in Next.js when we need to.

As you may have noticed, this will be a huuuge section, so let's get started! 🚀

Anatomy of a Next.js Page

Before we get into building the features, let's take a moment to understand the anatomy of a Next.js Page.

By default - Next.js uses Server Components for rendering every component, unless we opt-out of it.

Server Components are components rendered exclusively on the server, unlike Client Components that are rendered both on the server and the client (although the name is a bit misleading).

Generally speaking:

  1. Server Components render the skeleton of the page, and pass the data down to Client Components. Imagine this as render-only subset of React, where its main objective is to render the page with data fetched at runtime.
  2. Client Components add interactivity to the page, and are rendered both on the server, and then hydrated on the client. We can use hooks such as useState, useEffect, and others in Client Components. Do they render only on the client? No! They render on the server too, but they are hydrated on the client.

If every component is a Server Component, then you turn it into a Client Component by opting out of it. This is done by using the use client directive at the top of the file.

'use client'; // This component is now a Client Component

In the image above, we can see the anatomy of a Next.js Page using Server Components and Client Components.

Image the following file structure:

- home - layout.tsx - page.tsx

We could "think" of it as the following JSX structure:

<Layout> <Page /> </Layout>

At every boundary between layouts and pages, Next.js will automatically switch to Server Components, until you opt out of a component, where all its tree will be Client Components.

Imagine the following layout:

export default function MyLayout(props: React.PropsWithChildren) { return ( <MyClientComponent> {props.children} </MyClientComponent> ); }

We can "think" of it as the following JSX structure:

<Layout> <--- Server Component <MyClientComponent> <--- Client Component <Page /> <--- Server Component </MyClientComponent> </Layout>

As you can see, the Layout component is a Server Component, and the MyClientComponent is a Client Component. However, the Page component is a Server Component, and will render server components until we opt out of it.

In the image above, we can see how the Server Component is responsible for fetching the data, and passing it down to the Client Component and another Server Component.

Both Server Components and Client Components will render the data, but some components need to be client components if you attach an event handler (onClick), or if you need React Hooks, or anything that is purely interactive.

We have now covered the basics of Server Components and Client Components in Next.js. For more information, you can refer to the Next.js documentation.

Understanding where to place Routes in Makerkit

SaaS applications usually falls into two categories: B2B and B2C. Makerkit is flexible to adapt to both, but in this case, we're really building a pure B2B SaaS application.

As such - we want to place our routes in the /home/[account] layout - i.e. the team account layout. In fact - this application is meant to be used by teams of users, unlike a common B2C application where it's just one user.

As I said - Makerkit supports both, but in this case, we're focusing on B2B.

Adding the Team Accounts List to the User home page

When customers log in to the application, they will be redirected to the /home route. This is the user home page, and depending on your application, you may want to display a list of team accounts the user is a member of.

To add this list, you will import the HomeAccountsList component:

apps/web/app/home/(user)/page/tsx
import { HomeAccountsList } from './_components/home-accounts-list';
apps/web/app/home/(user)/page/tsx
<PageBody> <HomeAccountsList /> </PageBody>

This is the ideal scenario in B2B SaaS applications, where users can be part of multiple team accounts. The HomeAccountsList component will display a list of team accounts the user is a member of.

However, if you're doing B2C - you may want to display different data here, as it's the user's home page.

Listing Support Tickets

Our first task is to create a page where support agents can view new support tickets. We will be fetching the data from the database and displaying it on the page.

We already have our Database setup (if not, please refer to the previous section) - but we now need the:

  1. Pages: the pages where we will display the support tickets.
  2. Components: the components that will display the support tickets data.
  3. Database: the queries that will fetch the support tickets data from the database.

Adding a menu Entry for Support Tickets

To create a menu entry to the Account Layout, we need to modify the team account navigation configuration.

In the above, we add the Support Tickets menu entry to the team account navigation configuration. We point to the /home/[account]/tickets route, which we will create in the next step.

apps/web/config/team-account-navigation.config.tsx
import { CreditCard, LayoutDashboard, Settings, Users, MessageCircle } from 'lucide-react'; //.. const getRoutes = (account: string) => [ { label: 'common:dashboardTabLabel', path: pathsConfig.app.accountHome.replace('[account]', account), Icon: <LayoutDashboard className={iconClasses} />, end: true, }, { label: 'Support Tickets', collapsible: false, path: `/home/${account}/tickets`, Icon: <MessageCircle className={iconClasses} />, }, { label: 'common:settingsTabLabel', collapsible: false, children: [ { label: 'common:settingsTabLabel', path: createPath(pathsConfig.app.accountSettings, account), Icon: <Settings className={iconClasses} />, }, { label: 'common:accountMembers', path: createPath(pathsConfig.app.accountMembers, account), Icon: <Users className={iconClasses} />, }, featureFlagsConfig.enableTeamAccountBilling ? { label: 'common:billingTabLabel', path: createPath(pathsConfig.app.accountBilling, account), Icon: <CreditCard className={iconClasses} />, } : undefined, ].filter(Boolean), }, ];

Creating the Support Tickets Page and retrieving data

This is meant as a B2B SaaS - and as such we will be adding our routes in the /home/[account] layout.

Let's begin by adding a new route at /home/[account]/tickets by adding a new file pages/home/[account]/tickets/page.tsx.

NB: the kit uses i18n throughout the application, but for simplicity, we will not be adding i18n to the course and will be adding English text directly into the components.

pages/home/[account]/tickets/page.tsx
import { PageHeader, PageBody } from '@kit/ui/page'; export default function TicketsPage() { return ( <> <PageHeader title={'Support Tickets'} description={'Here is the list of the support tickets from your customers'} /> <PageBody> {/* Add the support tickets list here */} </PageBody> </> ); }

We have added a quick page layout with a title and description. Please log in to the default team account and then navigate to the /home/[account]/tickets route to see the page to see the page.

Yay, we have a new page! 🎉 Let's now fetch the support tickets data from the database and display it on the page.

Fetching Support Tickets Data

To display the support tickets data on the page, we need to fetch the data from the database. As we use Supabase, we will be using the Supabase client to fetch the data.

Quick introduction to Supabase Clients and Next.js

In Next.js, we need to create multiple instances of the Supabase client, given the fact that cookies are handled differently on the various Next.js boundaries (route handlers, server actions, middleware, etc.).

We have 4 clients you need to be aware of, and depending on where you are in the application, you will use one of the following clients.

The Supabase Server Components Client

This is the client that is used in Server Components.

import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; export default async function TasksPage() { const supabase = getSupabaseServerComponentClient(); const { data } = await supabase.from('users').select('*'); return <TasksList tasks={data} /> }
The Supabase Server Actions Client

This is the client that is used in Server Actions.

'use server'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; export async function myServerAction() { const supabase = getSupabaseServerActionClient(); const { data, error } = await supabase.from('users').select('*') return { success: true, }; }
The Supabase Route Handlers Client

This is the client that is used in route handlers.

import { NextRequest, NextResponse } from 'next/server'; import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handlers-client'; export async function POST(req: NextRequest) { const supabase = getSupabaseRouteHandlerClient(); const { data, error } = await supabase.from('users').select('*') return NextResponse.json({ data }); }
The Supabase Browser Client

This is the client that is used in the browser.

import { useSupabase } from '@kit/supabase/hooks/use-supabase'; export default function Home() { const supabase = useSupabase() return ( <div> <h1>Supabase Browser Client</h1> <button onClick={() => supabase.auth.signOut()}>Sign Out</button> </div> ) }

Creating a Service to fetch Support Tickets

Next.js offers a lot of freedom in how you structure your application, so we need to self-discipline ourselves to create a consistent set of patterns for a cohesive and maintainable codebase.

In my quest to make this as simple but also as maintainable as possible, I tend to create services that encapsulate the logic for fetching data from the database. These receive a SupabaseClient instance and provide the methods needed to interact with the Supabase DB.

In the core kit - I do this using Turborepo packages. However, for the sake of simplicity, we will instead use the apps/web/lib folder for adding shared/core logic that can be used across the application.

My services usually follow the following structure:

export function createService(client: SupabaseClient) { return new MyService(client); } class MyService { constructor(client: SupabaseClient) {} }

If you don't like this pattern, you can use any pattern you like. The goal is to have a consistent pattern across the application, whichever this may be.

Let's create a new file apps/web/lib/server/tickets/tickets.service.ts and add the following code:

apps/web/lib/server/tickets/tickets.service.ts
import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '~/lib/database.types'; export function createTicketsService(client: SupabaseClient<Database>) { return new TicketsService(client); } class TicketsService { constructor(private readonly client: SupabaseClient<Database>) {} async getTickets(params: { accountSlug: string; page: number; limit?: number; query?: string; }) { const limit = params.limit ?? 10; const startOffset = (params.page - 1) * limit; const endOffset = startOffset + limit; let query = this.client .from('tickets') .select('*, account_id !inner (slug)', { count: 'exact', }) .eq('account_id.slug', params.accountSlug) .order('created_at', { ascending: false }) .range(startOffset, endOffset); if (params.query) { query = query.textSearch('title', `"${params.query}"`); } const { data, error, count } = await query; if (error) { throw error; } return { data: data ?? [], count: count ?? 0, pageSize: limit, page: params.page, pageCount: Math.ceil((count ?? 0) / limit), }; } }

Let's break down the code above:

  1. We create a createTicketsService function that receives a SupabaseClient instance and returns a new TicketsService instance.
  2. The TicketsService class has a getTickets method that fetches the support tickets data from the database. It receives the accountSlug, page, limit, and query as parameters.
  3. We use the client.from('tickets') method to fetch the support tickets data from the tickets table in the database. We use the eq method to filter the support tickets by the accountSlug.
  4. We use the range method to paginate the support tickets data. We calculate the startOffset and endOffset based on the page and limit parameters.
  5. We use the textSearch method to search the support tickets data by the title field if the query parameter is provided.
  6. We return a set of data that includes the support tickets data, the total count of support tickets, the number of support tickets per page, the current page, and the total number of pages. This is very useful for our Table component to display the data and for pagination.

A couple of things you should note:

  1. We are expanding the data returned '*, account_id !inner (slug)' to include the joined table accounts to get the account slug. This is done so we can filter the tickets by the account slug without having to do a separate query for the account ID.
  2. We are using the exact count method to get the exact count of the support tickets data. This is useful for pagination.

Let's now use the TicketsService to fetch the support tickets data and display it on the page.

pages/home/[account]/tickets/page.tsx
import { use } from 'react'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { DataTable } from '@kit/ui/enhanced-data-table'; import { PageBody, PageHeader } from '@kit/ui/page'; import { createTicketsService } from '~/lib/server/tickets/tickets.service'; interface TicketsPageProps { params: { account: string; }; searchParams: { page?: string; query?: string; }; } export default function TicketsPage(props: TicketsPageProps) { const client = getSupabaseServerComponentClient(); const service = createTicketsService(client); const page = Number(props.searchParams.page ?? '1'); const query = props.searchParams.query ?? ''; const { data, pageSize, pageCount } = use( service.getTickets({ accountSlug: props.params.account, page, query, }), ); return ( <> <PageHeader title={'Support Tickets'} description={ 'Here is the list of the support tickets from your customers' } /> <PageBody> <DataTable pageIndex={page - 1} pageCount={pageCount} pageSize={pageSize} data={data} columns={[]} /> </PageBody> </> ); }

Let's take a quick look at the code above.

  • First, we define the interface of the component props. At this time, it's still a manual process to define the props, but in the future, Next.js may do it for us:
interface TicketsPageProps { params: { account: string; }; searchParams: { page?: string; query?: string; }; }

The interface above defines the props that the component will receive. We have two parameters:

  1. searchParams: we have a URL with a query parameter page and query that we can use to paginate and search the support tickets data.
  2. params: we have the account parameter that we can use to filter the support tickets data by the account slug in our path: /home/[account]/tickets.

When you have a Layout or a Page Server component, you will receive the params and searchParams as props.

export default function TicketsPage( { params, searchParams }: TicketsPageProps ) { //... }

NB: pages are always exported as default in Next.js.

Then, we use the getSupabaseServerComponentClient function to get the Supabase client instance. We use the createTicketsService function to create a new TicketsService instance.

pages/home/[account]/tickets/page.tsx
export default function TicketsPage(props: TicketsPageProps) { const client = getSupabaseServerComponentClient(); const service = createTicketsService(client); const page = Number(props.searchParams.page ?? '1'); const query = props.searchParams.query ?? ''; const { data, pageSize, pageCount } = use( service.getTickets({ accountSlug: props.params.account, page, query, }) ); //...

We use the service.getTickets to fetch the tickets using the parameters provided, and we use the use hook to "syncify" the data fetching process (as you can see, we don't use async/await). However, you could switch to async/await and replace use.

Finally, we display the support tickets data using the DataTable component. We pass the data, pageSize, pageCount, and other props to the DataTable component, which will render accordingly.

Wait a second... 🤔

Small issue here: the columns prop is empty, so the DataTable will not render any data. Let's fix this by creating a new component that will wrap the DataTable component and define the columns for the support tickets data.

Displaying the Support Tickets Data

We will be using the DataTable component from the @kit/ui/enhanced-data-table package to display the support tickets data. The DataTable component is a powerful and flexible component that allows you to display data in a table format.

This component wraps the vanilla @kit/ui/enhanced-data-table from Shadcn UI with more functionality related to pagination.

In the steps above, we have mentioned that the columns prop is empty. Here we define the React Table props that define how the columns are rendered. However, we have a small issue: we need to pass functions down to the DataTable component, which is a client component! Unfortunately, we can only pass data that can be serialized, and as you can imagine, functions cannot be serialized.

So - here's a good learning point: if you need to pass functions down to a client component, you will need to wrap the component into a client component and define the functions there.

Therefore, the solution is to create a separate client component named TicketsDataTable that will wrap the DataTable component and define the columns and other functions there. Easy, let's do it!

We also want two reusable components for displaying the status and priority of the support tickets. We will be using the Badge component from the Shadcn UI kit to display the status and priority of the support tickets.

pages/home/[account]/tickets/_components/ticket-status-badge.tsx
import { Badge } from '@kit/ui/badge'; import { Tables } from '~/lib/database.types'; export function TicketStatusBadge({ status, }: { status: Tables<'tickets'>['status']; }) { switch (status) { case 'open': return <Badge variant={'warning'}>Open</Badge>; case 'closed': return <Badge variant={'secondary'}>Closed</Badge>; case 'resolved': return <Badge variant={'success'}>Resolved</Badge>; case 'in_progress': return <Badge variant={'info'}>In Progress</Badge>; } }

And the priority badge:

pages/home/[account]/tickets/_components/ticket-priority-badge.tsx
import { Badge } from '@kit/ui/badge'; import { Tables } from '~/lib/database.types'; export function TicketPriorityBadge({ priority, }: { priority: Tables<'tickets'>['priority']; }) { switch (priority) { case 'low': return <Badge variant={'outline'}> Low </Badge>; case 'medium': return <Badge variant={'warning'}> Medium </Badge>; case 'high': return <Badge variant={'destructive'}> High </Badge>; } }

Let's create a new file pages/home/[account]/tickets/_components/tickets-data-table.tsx and add the following code:

pages/home/[account]/tickets/_components/tickets-data-table.tsx
'use client'; import Link from 'next/link'; import { ColumnDef } from '@tanstack/react-table'; import { Button } from '@kit/ui/button'; import { DataTable } from '@kit/ui/enhanced-data-table'; import { Tables } from '~/lib/database.types'; import { TicketPriorityBadge } from './ticket-priority-badge'; import { TicketStatusBadge } from './ticket-status-badge'; type Ticket = Tables<'tickets'>; export function TicketsDataTable(props: { data: Ticket[]; pageSize: number; pageIndex: number; pageCount: number; }) { return <DataTable {...props} columns={getColumns()} />; } function getColumns(): ColumnDef<Ticket>[] { return [ { header: 'Title', cell({ row }) { const ticket = row.original; return ( <Link className={'hover:underline'} href={`tickets/${ticket.id}`}> {ticket.title} </Link> ); }, }, { header: 'Status', cell({ row }) { const ticket = row.original; return <TicketStatusBadge status={ticket.status} />; }, }, { header: 'Priority', cell({ row }) { const ticket = row.original; return <TicketPriorityBadge priority={ticket.priority} />; }, }, { header: 'Created At', cell({ row }) { const ticket = row.original; const date = new Date(ticket.created_at); return getDateString(date); }, }, { header: 'Updated At', cell({ row }) { const ticket = row.original; const date = new Date(ticket.updated_at); return getDateString(date); }, }, { header: '', id: 'actions', cell({ row }) { return ( <div className={'flex justify-end'}> <Button asChild variant={'outline'}> <Link href={`tickets/${row.original.id}`}>View Issue</Link> </Button> </div> ); }, }, ]; } function getDateString(date: Date) { const day = date.toLocaleDateString(); const time = date.toLocaleTimeString(); return `${day} at ${time}`; }

In this component, we define the TicketsDataTable component that wraps the DataTable component and defines the columns for the support tickets data. We define the getColumns function that returns an array of columns for the support tickets data.

We use a variety of components built into the kit, such as Badge, Button (from Shadcn UI) to display the support tickets data. We also define the getDateString function to format the date string.

We can now go back to the pages/home/[account]/tickets/page.tsx file and import the TicketsDataTable component to display the support tickets data.

pages/home/[account]/tickets/page.tsx
import { use } from 'react'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { PageBody, PageHeader } from '@kit/ui/page'; import { createTicketsService } from '~/lib/server/tickets/tickets.service'; import { TicketsDataTable } from './_components/tickets-data-table'; interface TicketsPageProps { params: { account: string; }; searchParams: { page?: string; query?: string; }; } export default function TicketsPage(props: TicketsPageProps) { const client = getSupabaseServerComponentClient(); const service = createTicketsService(client); const page = Number(props.searchParams.page ?? '1'); const query = props.searchParams.query ?? ''; const { data, pageSize, pageCount } = use( service.getTickets({ accountSlug: props.params.account, page, query, }), ); return ( <> <PageHeader title={'Support Tickets'} description={ 'Here is the list of the support tickets from your customers' } /> <PageBody> <TicketsDataTable pageIndex={page - 1} pageCount={pageCount} pageSize={pageSize} data={data} /> </PageBody> </> ); }

We have now imported the TicketsDataTable component and passed the data, pageSize, pageCount, and other props to the TicketsDataTable component. The TicketsDataTable component will render the support tickets data in a table format.

The page will now fetch the data from the DB and display it in a table format. You can now navigate to the /home/[account]/tickets route to see the support tickets data.

Conclusion

In this section, we learned how to set up routing, fetch data, and write data in your Makerkit Next.js Supabase Turbo project. We built the following:

  • Support Tickets List: A page for team accounts where support agents can view new support tickets.

We also learn the following Next.js concepts:

  1. Server Components and Client Components
  2. The anatomy of a Next.js Page
  3. Basics of Routing in Next.js

In the next section, we want to deep dive into the following:

  1. Displaying a Support Ticket's detail by routing to a new page.
  2. Submitting messages to the support ticket.
  3. Updating the state of the support ticket.

We will also learn the following:

  1. Fetching data with React Query
  2. Writing data to the database with Server Actions
  3. Using React Hook Form to build forms

Cool! Let's move on to the next section. 🚀