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: Creating 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:
- 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.
- Querying Supabase: Fetching data from the database using Supabase in Next.js.
- Server Components: Rendering data using React Server Components in Next.js
- 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:
- 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. They're only rendered on the server, and never hydrated on the client.
- Client Components add interactivity to the page, and are rendered both on the server and 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:
import { HomeAccountsList } from './_components/home-accounts-list';
<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:
- Pages: the pages where we will display the support tickets.
- Components: the components that will display the support tickets data.
- 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.
import { CreditCard, LayoutDashboard, Settings, Users, MessageCircle } from 'lucide-react';
//..
const getRoutes = (account: string) => [
{
label: 'common:routes.application',
children: [
{
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 apps/web/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.
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
Depending on whether you are running your code in the browser or on the server, you will need to use different clients to interact with Supabase.
Using the Supabase client in the browser
To import the Supabase client in a browser environment, you can use the useSupabase
hook:
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
function MyComponent() {
const supabase = useSupabase();
// Use the supabase client here
return <div>My Component</div>;
}
Using the Supabase client in a Server environment
To import the Supabase client in a server environment, you can use the getSupabaseServerClient
function, and you can do the same across all server environments like Server Actions, Route Handlers, and Server Components:
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function myServerAction() {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase.from('users').select('*')
return {
success: true,
};
}
To use the Server Admin, e.g. an admin client with elevated privileges, you can use the getSupabaseServerAdminClient
function:
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export async function myServerAction() {
const supabase = getSupabaseServerAdminClient();
const { data, error } = await supabase.from('users').select('*')
return {
success: true,
};
}
NB: The getSupabaseServerAdminClient
function should only be used in server environments, as it requires the SUPABASE_SERVICE_ROLE_KEY
environment variable to be set. Additionally, it should only be used in very exceptional cases, as it has elevated privileges. In most cases, please use the getSupabaseServerClient
function.
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:
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:
- We create a
createTicketsService
function that receives aSupabaseClient
instance and returns a newTicketsService
instance. - The
TicketsService
class has agetTickets
method that fetches the support tickets data from the database. It receives theaccountSlug
,page
,limit
, andquery
as parameters. - We use the
client.from('tickets')
method to fetch the support tickets data from thetickets
table in the database. We use theeq
method to filter the support tickets by theaccountSlug
. - We use the
range
method to paginate the support tickets data. We calculate thestartOffset
andendOffset
based on thepage
andlimit
parameters. - We use the
textSearch
method to search the support tickets data by thetitle
field if thequery
parameter is provided. - 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:
- We are expanding the data returned
'*, account_id !inner (slug)'
to include the joined tableaccounts
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. - 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.
import { use } from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-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: Promise<{
account: string;
}>;
searchParams: Promise<{
page?: string;
query?: string;
}>;
}
export default function TicketsPage(props: TicketsPageProps) {
const client = getSupabaseServerClient();
const service = createTicketsService(client);
const { account } = use(props.params);
const { page: pageParam, query = '' } = use(props.searchParams);
const page = Number(pageParam ?? '1');
const { data, pageSize, pageCount } = use(
service.getTickets({
accountSlug: 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:
- searchParams: we have a URL with a query parameter
page
andquery
that we can use to paginate and search the support tickets data. - 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 getSupabaseServerClient
function to get the Supabase client instance. We use the createTicketsService
function to create a new TicketsService
instance.
export default function TicketsPage(props: TicketsPageProps) {
const client = getSupabaseServerClient();
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.
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:
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 apps/web/home/[account]/tickets/_components/tickets-data-table.tsx
and add the following code:
'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 apps/web/home/[account]/tickets/page.tsx
file and import the TicketsDataTable
component to display the support tickets data.
import { use } from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-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 = getSupabaseServerClient();
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 learned the following Next.js concepts:
- Server Components and Client Components
- The anatomy of a Next.js Page
- Basics of Routing in Next.js
In the next section, we want to deep dive into the following:
- Displaying a Support Ticket's detail by routing to a new page.
- Submitting messages to the support ticket.
- Updating the state of the support ticket.
We will also learn the following:
- Fetching data with React Query
- Writing data to the database with Server Actions
- Using React Hook Form to build forms
Cool! Let's move on to the next section. 🚀