Development: adding custom features

Learn how to get started developing new features in your app

It's time to work on our application's value proposition: adding and tracking tasks! This is likely the most exciting part for you because it's where you get to change things and add your SaaS features to the template.

Routing Structure

Before getting started, let's take a look at the default page structure of the boilerplate is the following.

├── app
  └── layout.tsx

  └── onboarding

  __site
    └── faq
    └── pricing

  └── __auth
    └── link
    └── password-reset
    └── sign-in
    └── sign-up

  └── invite
    └── [code]
      └── page.tsx

  └── (app)
    └── dashboard
      └── page.tsx

    └── settings
      └── organization
        └── members
          └── page.tsx
          └── invite
            └── page.tsx

      └── profile
        └── index
        └── email
        └── password
        └── authentication

      └── subscription

  └── page.tsx

The routes are split in the following way:

  1. The website's pages are placed under (site)
  2. The auth pages are placed under auth
  3. The internal pages (behind auth) pages are placed under (app)

Some pages in the "middle" are placed outside (app), such as the Invites page and the Onboarding flow. These require custom handling.

Setting the application's Home Page

By default, the application's home page is /dashboard; every time the user logs in, they're redirected to the page src/app/(app)/dashboard/page.tsx.

Demo: Tasks Application

Our demo application will be a simple tasks management application. We will be able to create tasks, list them, mark them as completed, and delete them.

Here is a quick demo of what we will build:

Loading video...

Routing

Ok, so we want to add three pages to our application:

  1. List: A page to list all our tasks
  2. New Task: Another page to create a new task
  3. Task: A page specific to the selected task

To create these two pages, we will create a folder named tasks at src/app/(app)/tasks. In the folder src/app/(app)/tasks we will create three Page Components:

  1. The tasks list page at tasks/page.tsx
  2. The form to create a new task at tasks/new/page.tsx
  3. The task's own page at tasks/[task]/page.tsx.
├── app
  └── (app)
    └── tasks
      └── page.tsx
      └── new
        └── page.tsx
      └── [task]
        └── page.tsx

1. List Page

We create a page page.tsx, which is accessible at the path /tasks.

import React, { use } from 'react';
import { RectangleStackIcon } from '@heroicons/react/24/outline';
import { cookies } from 'next/headers';
 
import { parseOrganizationIdCookie } from '~/lib/server/cookies/organization.cookie';
import getSupabaseServerClient from '~/core/supabase/server-client';
import { getTasks } from '~/lib/tasks/queries';
import AppHeader from '~/app/(app)/components/AppHeader';
import Trans from '~/core/ui/Trans';
import AppContainer from '~/app/(app)/components/AppContainer';
import TasksContainer from '~/app/(app)/tasks/components/TasksListContainer';
 
export const metadata =  {
  title: 'Tasks',
};
 
function TasksPage() {
  const tasks = use(loadTasksData());
 
  return (
    <>
      <AppHeader>
        <span className={'flex space-x-2'}>
          <RectangleStackIcon className="w-6" />
 
          <span>
            <Trans i18nKey={'common:tasksTabLabel'} />
          </span>
        </span>
      </AppHeader>
 
      <AppContainer>
        <TasksContainer tasks={tasks} />
      </AppContainer>
    </>
  );
}
 
export async function loadTasksData() {
  const organizationId = await parseOrganizationIdCookie(cookies());
  const client = getSupabaseServerClient();
 
  const { data: tasks, error } = await getTasks(client, Number(organizationId));
 
  if (error) {
    return [];
  }
 
  return tasks;
}
 
export default TasksPage;

We use the function loadTasksData to fetch the data from the RSC component TasksPage.

To retrieve the selected organization ID, we can retrieve it from the cookie using parseOrganizationIdCookie(cookies()): the cookie is set in upper (app) layout, and may not be finished running when the page is rendered. To avoid this, if you're requiring the organization ID in the application home page, you will want to use the following pattern:

export async function loadTasksData() {
  const client = getSupabaseServerClient();
  const session = await requireSession(client);
 
  if ('redirect' in session) {
    return redirect(session.destination);
  }
 
  const userId = session.user.id;
  const organizationCookie = await parseOrganizationIdCookie(cookies());
 
  let organizationId = Number(organizationCookie);
 
  if (!organizationId) {
    const result = await getCurrentOrganization(client, { userId });
 
    if (!result) {
      return redirect(configuration.paths.onboarding);
    }
 
    organizationId = result.organization.id;
  }
 
  const { data: tasks, error } = await getTasks(client, organizationId);
 
  if (error) {
    return [];
  }
 
  return tasks;
}

In the above, we verify that organizationCookie is defined, and if not, we retrieve the current organization from the database (ie. the first one in the user's memberships). If no organization is found, we redirect the user to the onboarding flow.

This ensures the organization ID is always defined when we need it.

2. New Task Page

We create a page new/page.tsx, which is accessible at the path /tasks/new.

src/app/(app)/tasks/new/page.tsx
import React from 'react';
import { RectangleStackIcon } from '@heroicons/react/24/outline';
 
import AppHeader from '~/app/(app)/components/AppHeader';
import AppContainer from '~/app/(app)/components/AppContainer';
import CreateTaskForm from '~/app/(app)/tasks/components/CreateTaskForm';
 
import Trans from '~/core/ui/Trans';
 
export const metadata = {
  title: 'New Task',
};
 
const NewTaskPage = () => {
  return (
    <>
      <AppHeader>
        <span className={'flex space-x-2'}>
          <RectangleStackIcon className="w-6" />
 
          <span>
            <Trans i18nKey={'common:createTaskLabel'} />
          </span>
        </span>
      </AppHeader>
 
      <AppContainer>
        <div className={'max-w-lg'}>
          <CreateTaskForm />
        </div>
      </AppContainer>
    </>
  );
};
 
export default NewTaskPage;

3. Task Page

We create a page [task]/page.tsx, which is accessible at the path /tasks/[task].

We will define TaskItemContainer in the following sections.

src/app/(app)/tasks/[task]/page.tsx
import React, { use } from 'react';
import { redirect } from 'next/navigation';
 
import ArrowLeftIcon from '@heroicons/react/24/outline/ArrowLeftIcon';
 
import Heading from '~/core/ui/Heading';
import Button from '~/core/ui/Button';
import AppHeader from '~/app/(app)/components/AppHeader';
import AppContainer from '~/app/(app)/components/AppContainer';
import TaskItemContainer from '~/app/(app)/tasks/components/TaskItemContainer';
 
import getSupabaseServerClient from '~/core/supabase/server-client';
import { getTask } from '~/lib/tasks/queries';
 
interface Context {
  params: {
    task: string;
  };
}
 
export const metadata = {
  title: `Task`,
};
 
const TaskPage = ({ params }: Context) => {
  const data = use(loadTaskData(params.task));
  const task = data.task;
 
  return (
    <>
      <AppHeader>
        <TaskPageHeading />
      </AppHeader>
 
      <AppContainer>
        <TaskItemContainer task={task} />
      </AppContainer>
    </>
  );
};
 
function TaskPageHeading() {
  return (
    <div className={'flex items-center space-x-6'}>
      <Heading type={4}>
        <span>Task</span>
      </Heading>
 
      <Button size={'small'} color={'transparent'} href={'/tasks'}>
        <ArrowLeftIcon className={'mr-2 h-4'} />
        Back to Tasks
      </Button>
    </div>
  );
}
 
export async function loadTaskData(taskId: string) {
  const client = getSupabaseServerClient();
  const { data: task } = await getTask(client, Number(taskId));
 
  if (!task) {
    return redirect('/tasks');
  }
 
  return {
    task,
  };
}
 
export default TaskPage;

Adding Functionalities to your application

To add new functionalities to your application (in our case, tasks management), usually, you'd need the following things:

  1. Add a new page, and the links to it
  2. Add the components of the domain and import them into the pages
  3. Add data-fetching and writing to this domain's entities

The above are the things we will do in the following few sections. To clarify further the conventions of this boilerplate, here is what you should know:

  1. Data Model: first, we want to define the data model of the feature you want to add
  2. Firestore Hooks: once we're happy with the data model, we can create our Firestore hooks to write new tasks and then fetch the ones we created
  3. Import hooks into components: then, we import and use our hooks within the components
  4. Add feature components into the pages: finally, we add the components to the pages

Updating the Top header Navigation

To update the navigation menu, we need to update the NAVIGATION_CONFIG object in src/navigation-config.tsx.

const NAVIGATION_CONFIG = {
  items: [
    {
      label: 'common:dashboardTabLabel',
      path: configuration.paths.appHome,
      Icon: ({ className }: { className: string }) => {
        return <Squares2X2Icon className={className} />;
      },
    },
    {
      label: 'common:settingsTabLabel',
      path: '/settings',
      Icon: ({ className }: { className: string }) => {
        return <Cog8ToothIcon className={className} />;
      },
    },
  ],
};

To add a new link to the header menu, we can add the following item in the NAVIGATION_CONFIG object:

{
  label: 'common:tasksTabLabel',
  path: '/tasks',
  Icon: ({ className }: { className: string }) => {
    return <Squares2X2Icon className={className} />;
  },
},

The result will be similar to the images below:


Subscribe to our Newsletter
Get the latest updates about React, Remix, Next.js, Firebase, Supabase and Tailwind CSS