• Documentation
  • /
  • Remix Supabase
  • /
  • Tutorial: Tasks Application

Tutorial: Tasks Application

Full walkthrough to building an application with Makerkit

This tutorial is a comprehensive guide to getting started with the Remix and Supabase template to build a SaaS, from fetching the repository to deploying the application.

By the end, you'll have a fully working application with the minimum basics a SaaS needs:

  1. Landing Page and Marketing pages (blog, documentation)
  2. Authentication (sign in, sign up)
  3. Payments (Stripe)
  4. Profile and Organization management

Prerequisites

To get started, you're going to need some things installed:

  1. Git
  2. Node.js version
  3. npm 7 or greater
  4. Docker
  5. A code editor (VSCode, WebStorm)
  6. If you'd like to deploy your application, you'll also want an account on Vercel.

Experience with React, TypeScript/JavaScript, and Supabase would be advantageous but not strictly required. The codebase can also serve as a way to learn these topics more in-depth.

If you have all the above installed, I guess we can get started!

Generating a new Makerkit Project

Assuming you have access to the repository, open your terminal and run this command (replace tasks-app with your name of choice):

git clone --depth=1 https://github.com/makerkit/remix-supabase-saas-kit
tasks-app

Once completed, we'll change into the tasks-app directory, and then we will install the Node modules:

cd tasks-app
npm i

Reinitialize Git

As the Git repository's remote points to Makerkit's original repository, you can re-initialize it and set the Makerkit repository as upstream:

rm -rf .git
git init
git remote add upstream https://github.com/makerkit/remix-supabase-saas-kit
git add .
git commit -a -m "Initial Commit"

By adding the Makerkit's repository as upstream remote, you can fetch updates (after committing your files) by running the following command:

git pull upstream main --allow-unrelated-histories

Project Structure

When inspecting the project structure, you will find something similar to the below:

tasks-app
├── README.md
├── @types
├── src
│   ├── components
│   ├── core
│   ├── lib
│   └── routes
        └── __app
        └── __site
        └── auth
        └── invite
        └── onboarding
│       └── root.tsx
├── package-lock.json
├── package.json
├── public
│   └── favicon.ico
└── tsconfig.json

NB: we omitted a lot of the files for simplicity.

Let's take a deeper look at the structure we see above:

  • src/core: this folder contains reusable building blocks of the template that are not related to any domain. This includes components, React hooks, functions, and so on.
  • src/components: this folder contains the application's components, including those that belong to your application's domain. For example, we can add the tasks components to src/components/tasks.
  • src/lib: this folder contains the business logic of your application's domain. For example, we can add the tasks hooks to write and fetch data from Firestore in src/lib/tasks.
  • src/routed: this is Remix folder where we add our application routes.

Don't worry if this isn't clear yet! We'll explain where we place our files in the sections ahead.

Running the project

When we run the stack of a Makerkit application, we will need to run a few commands before:

  1. Remix: First, we need to run the Remix development server
  2. Supabase: We need to run the Supabase local environment using Docker
  3. Stripe CLI (optional): If you plan on interacting with Stripe, you are also required to run the Stripe CLI, which allows us to test the webhooks from Stripe against our local server.

Running the Remix development server

Run the following command from your IDE or from your terminal:

npm run dev

This command will start the Remix server at localhost:3000. If you navigate to this page, you should be able to see the landing page of your application:

Running the Supabase Local environment

To run the Supabase local environment, we need to first run Docker. Then, open a new terminal (or, better, from your IDE) and run the following command:s

npm run supabase:start

If everything is working correctly, you will see the output below:

> supabase start

Applying migration 20221215192558_schema.sql...
Seeding data supabase/seed.sql...
Started supabase local development setup.

         API URL: http://localhost:54321
          DB URL: postgresql://postgres:postgres@localhost:54322/postgres
      Studio URL: http://localhost:54323
    Inbucket URL: http://localhost:54324
      JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
        anon key: ****************************************************
service_role key: ****************************************************

Now, we need to copy the anon key and service_role key values and add them to the .env file:

SUPABASE_ANON_KEY=****************************************************
SUPABASE_SERVICE_ROLE_KEY=****************************************************

When you need to stop the development environment, you can run the following command:

npm run supabase:stop

Supabase Studio UI

To access the Supabase Studio UI, open a new tab in your browser and navigate to http://localhost:54323.

Makerkit's template adds some data to your project by default for testing reasons. In fact, the data you see is used to run the Cypress E2E tests. This is why you'll see some pre-populated data in your project.

Running the Stripe CLI (optional)

Optionally, if you want to run Stripe locally (e.g., sending webhooks to your local server), you will also need to run the following command:

npm run stripe:listen

This command requires Docker, but you can alternatively install Stripe on your OS and change the command to use stripe directly.

The above command runs the Stripe CLI and will route webhooks coming from Stripe to your local endpoint. For example, in the Makerkit starter, this endpoint is /api/stripe/webhook.

When running the command, it will print a webhook key used to sign the messages from Stripe. You will need to add this key to your local environment variables file as below:

STRIPE_WEBHOOK_SECRET=<KEY>

The webhook printed should not change, so you may only need to do this the first time.

Project Configuration

Makerkit can be configured from a single place: the src/configuration.ts object. This file exports a constant we use throughout the project to read our application's settings.

We recommend adding additional configuration to this file rather than reading it directly from the environment variables. For example, assuming you rename one of your environment variables, you will only need to update it in one place. Additionally, it helps maintain your codebase more explicit and understandable.

We retrieve some of this file's configuration using environment variables: these help us swap and tweak our settings based on which environment we're using.

Here is what the configuration file looks like:

import getEnv from '~/core/get-env';
import type { Provider } from '@supabase/gotrue-js/src/lib/types';

const env = getEnv() ?? {};

const configuration = {
  site: {
    name: 'Awesomely - Your SaaS Title',
    description: 'Your SaaS Description',
    themeColor: '#ffffff',
    themeColorDark: '#0a0a0a',
    siteUrl: env.SITE_URL,
    siteName: 'Awesomely',
    twitterHandle: '',
    githubHandle: '',
    language: 'en',
    convertKitFormId: '',
    locale: env.DEFAULT_LOCALE,
  },
  auth: {
    // NB: Enable the providers below in the Supabase Console
    // in your production project
    providers: {
      emailPassword: true,
      phoneNumber: false,
      emailLink: false,
      oAuth: ['google'] as Provider[],
    },
  },
  production: env.NODE_ENV === 'production',
  environment: env.ENVIRONMENT,
  paths: {
    signIn: '/auth/sign-in',
    signUp: '/auth/sign-up',
    emailLinkSignIn: '/auth/link',
    onboarding: `/onboarding`,
    appHome: '/dashboard',
    settings: {
      profile: '/settings/profile',
      authentication: '/settings/profile/authentication',
      email: '/settings/profile/email',
      password: '/settings/profile/password',
    },
    api: {
      checkout: `/stripe/checkout`,
      billingPortal: `/stripe/portal`,
    },
    searchIndex: `/public/search-index`,
  },
  email: {
    host: '',
    port: 587,
    user: '',
    password: '',
    senderAddress: 'MakerKit Team <info@makerkit.dev>',
  },
  sentry: {
    dsn: env.SENTRY_DSN,
  },
  stripe: {
    plans: [
      {
        name: 'Basic',
        description: 'Description of your Basic plan',
        price: '$9/month',
        stripePriceId: '',
        trialPeriodDays: 0,
        features: [
          'Basic Reporting',
          'Up to 20 users',
          '1GB for each user',
          'Chat Support',
        ],
      },
    ],
  },
};

export default configuration;

Environment Variables

The starter project comes with two different environment variables files:

  1. .env: the main environment file
  2. .env.test: this environment file is loaded when running the Cypress E2E tests. You would rarely need to use this.

NB: the .env file is never committed to the repository. This is because it contains sensitive information, such as API keys. Instead, we use a .env. template file to show what the .env file should look like.

When you run the project locally, the .env file is loaded automatically. Instead, when you run the project in production, you will need to set the environment variables manually. For example, if you're using Vercel, you can do so from the project's settings.

This is how Remix works.

Tailwind CSS

This SaaS template uses Tailwind CSS for styling the application.

You will likely want to tweak the brand color of your application: to do so, open the file tailwind.config.js and replace the primary color (which it's blue by default):

extend: {
  colors: {
    primary: {
      ...colors.blue,
      contrast: '#fff',
    },
    black: {
      50: '#525252',
      100: '#363636',
      200: '#282828',
      300: '#222',
      400: '#121212',
      500: '#0a0a0a',
      600: '#040404',
      700: '#000',
    },
  },
},

To update the primary color, you can either:

  1. choose another color from the Tailwind CSS Color palette (for example, try colors.indigo, see the image below)
  2. define your own colors, from 50 to 900

The contrast color will be helpful to define the text color of some components, so tweaking it may be required.

Once updated, the Makerkit's theme will automatically adapt to your new color palette! For example, below, we set the primary color to colors.indigo:

Dark Mode

Makerkit ships with two themes: light and dark. The light theme is the default, but users can switch to the other, thanks to the component DarkModeToggle.

You can set the dark theme by default by adding the dark class to the html tag in your root element root.tsx:

<html className='dark'>
...
</html>

If you wish to only use the dark theme, you should remove the DarkModeToggle component from your application.

Loading video...

Alternatively, if you want to respect the user's system theme, update the Tailwind configuration and use the media strategy:

module.exports = {
  content: ['./app/**/*.tsx'],
  darkMode: 'media',
  // ...
}

Authentication

The Remix/Supabase template uses Supabase Auth to manage authentication into the internal application.

The kit supports the following strategies:

  1. Email/Password
  2. All the oAuth Providers supported by Supabase Auth (Google, GitHub, etc.)
  3. Phone Number

You can choose one, more, or all of them together, and you can easily tweak this using the global configuration.

By default, the Makerkit SaaS Starter uses Email/Password and Google Auth.

How does Authentication work?

First, let's just take a high-level overview of how Makerkit's authentication works.

MakerKit uses SSR throughout the application, except for the marketing pages. Using SSR, we can persist the user's authentication on every page and access the user's object on the server before rendering the page.

This can help you both in terms of UX and DX. In fact, persisting the user's session server-side can help you in various scenarios:

  • Simplifying the business logic: for example, checking server-side if a user can access a specific page before rendering that page
  • Rendering the user's data server-side by fetching some data from Supabase in the loader function of your Remix routes

Authentication Strategies

To add or tweak the authentication strategies of our application, we can update the configuration:

auth: {
  // NB: Enable the providers below in the Firebase Console
  // in your production project
  providers: {
    emailPassword: true,
    phoneNumber: false,
    emailLink: false,
    oAuth: ['google'],
  },
},

Above, you can see the default configuration. It should look like the following:

Ok, cool. But what if we wanted to swap emailPassword with emailLink and add Twitter oAuth?

We will do the following:

auth: {
  // NB: Enable the providers below in the Firebase Console
  // in your production project
  providers: {
    emailPassword: false, // set this to false
    phoneNumber: false,
    emailLink: true, // set this to true
    oAuth: ['google', 'twitter'],
  },
},

And the result will be similar to the image below:

Creating Accounts

Users can be redirected to the Sign Up page to create an account.

Onboarding Flow

After sign-up, users are redirected to the onboarding flow. Here, you can ask users questions, configure their accounts or simply show them around.

By default, Makerkit adds one single step, where it asks users to create an organization.

Of course, you can extend it and add as many steps as you wish.

After the user submits the form, the API will receive the request and:

  1. create the user Supabase record in the users table
  2. create the organization Supabase record in the organizations table
  3. create a membership between the user and the organization in the memberships table and assign the user the role owner

I encourage you to visit the [Supabase Studio UI] (http://localhost:54323) and see the data created.

What is an "Organization"?

Organizations are groups of users.

You can call them projects, teams, classrooms, or whatever feels suitable for your domain. But, generally speaking, organizations are the backbone of the data model because it's where we store most of the data shared among users.

Users can:

  1. create new Organizations
  2. be invited to other Organizations
  3. switch between organizations using a dropdown

Developing your SaaS

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.

├── routes
  └── root.tsx
  └── auth.tsx
  └── invite.tsx

  └── __app.tsx
  └── __site.tsx

  └── onboarding
      └── index.tsx

  __site
    └── auth.tsx
      └── faq.tsx
      └── pricing.tsx

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

  └── invite
    └── $code.tsx

  └── __app
    └── dashboard
      └── index.tsx

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

      └── profile
        └── index.tsx
        └── email.tsx
        └── password.tsx
        └── authentication.tsx

      └── subscription

  └── index.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/routes/__app/dashboard/index.tsx.

You can update the above by setting the application's home page path at configuration.paths.appHome.

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/routes/__app/tasks.

In the folder src/routes/__app/tasks we will create three Page Components:

  1. List Page: we create a page index.tsx, which is accessible at the path /tasks
  2. New Task Page: we create a page new.tsx, which is accessible at the path /tasks/new
  3. Task Page: we create a page $id.tsx, which is accessible at the path /tasks/<taskID> where taskID is a dynamic variable that refers to the actual ID of the task
├── routes
  └──__app
    └── tasks
    └── index.tsx
    └── new.tsx
    └── $id.tsx

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. First, we want to define our data model
  2. Once we're happy with the data model, we can create our Supabase hooks to write new tasks and then fetch the ones we created
  3. Then, we import and use our hooks within the components
  4. 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 image below:

Fetching data from Supabase

Before writing the React Components, we want to tackle the task of fetching data from Supabase.

This involves a couple of steps:

  1. We need to define, on paper, what the data model of the Supabase tables looks like
  2. We need to update the SQL schema of the database to match the data model and write the requires RLS policies to secure the data
  3. We need to write the hooks to fetch data from Supabase
How is the data structured?

We can place tasks as its own Postgres table tasks. Let's create an SQL table that defines the following columns:

create table tasks (
  id bigint generated always as identity primary key,
  organization_id bigint not null references public.organizations,
  name text not null,
  done bool not null,
  due_date timestamptz not null
);

Because tasks belong to an organization, we need to ensure that only users of that organization can read and write those tasks by adding a foreign key to each task named organization_id.

Replicating the tasks table as a Type

We can define a Task model at src/lib/tasks/types/task.ts:

export interface Task {
  id: number;
  name: string;
  organizationId: string;
  dueDate: string;
  done: boolean;
}

NB: this may not be necessary if you generate the types from the SQL schema. Up to you.

How do we protect the data with RLS?

We can add a RLS policy to the tasks table to ensure that only users of the same organization can read and write tasks.

We know that an user belongs to an organization using the memberships table.

The memberships table has two foreign keys: user_id and organization_id: we can use these foreign keys to ensure that only users of the same organization can read and write tasks.

create policy "Tasks can be read by users of the organizations to which it belongs" on tasks
  for all
    using (exists (
      select
        1
      from
        memberships
      where
        user_id = auth.uid () and tasks.organization_id = memberships.organization_id));

Generating the types from the SQL schema

We can generate the types from the SQL schema using the supabase-js library. To run this command, you need to ensure Supabase is running running npm run supabase:start.

You can do so by running the following command:

npm run typegen

The types will be generated at app/database-types.ts. We will import the types whenever we use the Supabase client.

For example:

import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '../../database.types';

type Client = SupabaseClient<Database>;

Data Fetching: React Hooks to read data from Supabase Postgres

With our tables and RLS in place, we can finally start writing our queries using the Supabase Postgres client.

I recommend writing your entities' hooks at lib/tasks/hooks. Let's start with a React Hook to fetch all the organization's tasks.

First, we want to write a function that is responsible for fetching the tasks using the Supabase Postgres client. We can write this function at lib/tasks/queries.ts:

import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '../../database.types';
import { TASKS_TABLE } from '~/lib/db-tables';

type Client = SupabaseClient<Database>;

export function getTasks(client: Client, organizationId: number) {
  return client
    .from(TASKS_TABLE)
    .select(
      `
      id,
      name,
      organization_id: organizationId,
      due_date: dueDate,
      done
    `
    )
    .eq('organization_id', organizationId)
    .throwOnError();
}

The query above will be fetching all the tasks of the organization with the ID organizationId.

As you can see, my convention (used throughout the boilerplate) is to write queries that inject the Supabase client as a parameter. This allows me to reuse these functions in both the browser and the server.

Additionally, I also like to write queries that throw errors. This allows me to catch errors using try/catch blocks rather than using the error property. This is a personal preference, feel free to use the error property instead.

Writing the React Hook to fetch the tasks

Now that we have an isomorphic function to fetch the tasks, we can write a React Hook to fetch the tasks.

In the boilerplate, we use react-query as a wrapper around the Supabase client. This can help with coordinating refetching, caching, and general state management using hooks when fetching data.

We can write this hook at lib/tasks/hooks/use-fetch-tasks.ts:

import useSupabase from '~/core/hooks/use-supabase';
import { useQuery } from '@tanstack/react-query';
import { getTasks } from '~/lib/tasks/queries';

export function useFetchTasks(
  organizationId: number
) {
  const client = useSupabase();
  const key = ['tasks', organizationId];

  return useQuery(
    key,
    async () => {
      return getTasks(client, organizationId).then((res) => res.data)
    },
  );
}

export default useFetchTasks;

Displaying Firestore data in Components

Now that we can fetch our data using the hook useFetchTasks, we can build a component that lists each Task.

To do so, let's create a new component named TasksListContainer:

import PageLoadingIndicator from '~/core/ui/PageLoadingIndicator';
import Alert from '~/core/ui/Alert';
import Heading from '~/core/ui/Heading';
import Button from '~/core/ui/Button';

import useFetchTasks from '~/lib/tasks/hooks/use-fetch-tasks';
import type Task from '~/lib/tasks/types/task';

const TasksContainer: React.FC<{
  organizationId: string;
}> = ({ organizationId }) => {
  const { data: tasks, error, isLoading } = useFetchTasks(organizationId);

  if (isLoading) {
    return <PageLoadingIndicator>Loading Tasks...</PageLoadingIndicator>;
  }

  if (error) {
    return (
      <Alert type={'error'}>
        Sorry, we encountered an error while fetching your tasks.
      </Alert>
    );
  }

  if (tasks.length === 0) {
    return <TasksEmptyState />;
  }

  return (
    <div className={'flex flex-col space-y-4'}>
      <div className={'mt-2 flex justify-end'}>
        <CreateTaskButton>New Task</CreateTaskButton>
      </div>

      <TasksList tasks={tasks} />
    </div>
  );
};

function TasksEmptyState() {
  return (
    <div
      className={
        'flex flex-col items-center justify-center space-y-4 h-full p-24'
      }
    >
      <div>
        <Heading type={5}>No tasks found</Heading>
      </div>

      <CreateTaskButton>Create your first Task</CreateTaskButton>
    </div>
  );
}

function TasksList({ tasks }: React.PropsWithChildren<{
  tasks: Task[]
}>) {
  return (
    <div className={'flex flex-col space-y-4'}>
      {tasks.map((task) => {
        return <TaskListItem task={task} key={task.id} />;
      })}
    </div>
  );
}

export default TasksList;


function CreateTaskButton(props: React.PropsWithChildren) {
  return <Button href={'/tasks/new'}>{props.children}</Button>;
}

export default TasksContainer;

Since the component "TaskLiskItem" is pretty complex and requires more files, we will define it separately. You will find it below after we explain the mutation hooks that the component will use.

Mutations: React Hooks to write data to the Supabase Postgres database

In the section above, we saw how to fetch data from the Supabase Postgres. Now, let's see how to write data to the Supabase database.

Creating a Task

First, we want to add a file named mutations.ts at lib/tasks/. Here, we will add all the mutations that we will need to create, update, and delete tasks.

In our mutations file, we will add all the mutations we want to perform on the tasks table. In this case, we will add a createTask mutation.

export function createTask(
  client: Client,
  task: Omit<Task, 'id'>
) {
  return client.from(TASKS_TABLE).insert({
    name: task.name,
    organization_id: task.organizationId,
    due_date: task.dueDate,
    done: task.done,
  });
}

Now, we can use the createTask mutation in our useCreateTask hook.

We will be using the useMutation hook from react-query to create our hook.

import { useMutation } from '@tanstack/react-query';
import useSupabase from '~/core/hooks/use-supabase';
import { createTask } from '~/lib/tasks/mutations';
import type Task from '~/lib/tasks/types/task';

function useCreateTaskMutation() {
  const client = useSupabase();

  return useMutation(async (task: Omit<Task, 'id'>) => {
    return createTask(client, task);
  });
}

export default useCreateTaskMutation;

And now, we could use this hook within a component:

function Component() {
  const createTaskMutation = useCreateTaskMutation();

  return <MyForm onSubmit={task => createTaskMutation.mutateAsync(task)} />
}

Updating a Task

Now, let's write a hook to update an existing task.

We will write another mutation function to update a task:

export function updateTask(
  client: Client,
  task: Partial<Task> & { id: number }
) {
  return client
    .from(TASKS_TABLE)
    .update({
      name: task.name,
      done: task.done,
    })
    .match({
      id: task.id,
    })
    .throwOnError();
}

And we can write a hook to use this mutation:

import { useMutation } from '@tanstack/react-query';
import useSupabase from '~/core/hooks/use-supabase';
import type Task from '~/lib/tasks/types/task';
import { updateTask } from '~/lib/tasks/mutations';

function useUpdateTaskMutation() {
  const client = useSupabase();

  return useMutation(async (task: Partial<Task> & { id: number }) => {
    return updateTask(client, task);
  });
}

export default useUpdateTaskMutation;

Deleting a Task

We will write another mutation function to delete a task:

export function deleteTask(
  client: Client,
  taskId: number
) {
  return client.from(TASKS_TABLE).delete().match({
    id: taskId
  }).throwOnError();
}

Finally, we write a mutation to delete a task:

import { useMutation } from '@tanstack/react-query';
import useSupabase from '~/core/hooks/use-supabase';
import { deleteTask } from '~/lib/tasks/mutations';

function useDeleteTaskMutation() {
  const client = useSupabase();

  return useMutation(async (taskId: number) => {
    return deleteTask(client, taskId);
  });
}

export default useDeleteTaskMutation;

Using the Modal component for confirming actions

The Modal component is excellent for asking for confirmation before performing a destructive action. In our case, we want the users to confirm before deleting a task.

The ConfirmDeleteTaskModal is defined below using the core Modal component:

import Modal from '~/core/ui/Modal';
import Button from '~/core/ui/Button';

const ConfirmDeleteTaskModal: React.FC<{
  isOpen: boolean;
  setIsOpen: (isOpen: boolean) => void;
  task: string;
  onConfirm: () => void;
}> = ({ isOpen, setIsOpen, onConfirm, task }) => {
  return (
    <Modal heading={`Deleting Task`} isOpen={isOpen} setIsOpen={setIsOpen}>
      <div className={'flex flex-col space-y-4'}>
        <p>
          You are about to delete the task <b>{task}</b>
        </p>

        <p>Do you want to continue?</p>

        <Button block color={'danger'} onClick={onConfirm}>
          Yep, delete task
        </Button>
      </div>
    </Modal>
  );
};

export default ConfirmDeleteTaskModal;

Wrapping all up: listing each Task Item

Now that we have defined the mutations we can perform on each task item, we can wrap it up.

The component below represents a task item and allows users to mark it as done or not done - or delete them.

import { useCallback, useState } from 'react';
import { Link, useNavigate } from '@remix-run/react';
import { TrashIcon } from '@heroicons/react/24/outline';
import toaster from 'react-hot-toast';
import { formatDistance } from 'date-fns';

import type Task from '~/lib/tasks/types/task';
import Heading from '~/core/ui/Heading';
import IconButton from '~/core/ui/IconButton';
import Tooltip from '~/core/ui/Tooltip';
import useDeleteTaskMutation from '~/lib/tasks/hooks/use-delete-task';
import ConfirmDeleteTaskModal from '~/components/tasks/ConfirmDeleteTaskModal';
import useUpdateTaskMutation from '~/lib/tasks/hooks/use-update-task';

const TaskListItem: React.FC<{
  task: Task;
}> = ({ task }) => {
  const getTimeAgo = useTimeAgo();
  const deleteTaskMutation = useDeleteTaskMutation();
  const updateTaskMutation = useUpdateTaskMutation();
  const navigate = useNavigate();

  const [isDeleting, setIsDeleting] = useState(false);

  const onDelete = useCallback(() => {
    const deleteTaskPromise = deleteTaskMutation.mutateAsync(task.id).then(() => {
      navigate('.')
    });

    return toaster.promise(deleteTaskPromise, {
      success: `Task deleted!`,
      loading: `Deleting task...`,
      error: `Ops, error! We could not delete task`,
    });
  }, [deleteTaskMutation, navigate, task.id]);

  const onDoneChange = useCallback(
    (done: boolean) => {
      const promise = updateTaskMutation.mutateAsync({ done, id: task.id });

      return toaster.promise(promise, {
        success: `Task updated!`,
        loading: `Updating task...`,
        error: `Ops, error! We could not update task`,
      });
    },
    [task.id, updateTaskMutation]
  );

  return (
    <>
      <div
        className={'rounded border p-4 transition-colors dark:border-black-400'}
      >
        <div className={'flex items-center space-x-4'}>
          <div>
            <Tooltip content={task.done ? `Mark as not done` : `Mark as done`}>
              <input
                className={'Toggle cursor-pointer'}
                type="checkbox"
                defaultChecked={task.done}
                onChange={(e) => {
                  return onDoneChange(e.currentTarget.checked);
                }}
              />
            </Tooltip>
          </div>

          <div className={'flex flex-1 flex-col space-y-0.5'}>
            <Heading type={5}>
              <Link className={'hover:underline'} to={`/tasks/${task.id}`}>
                {task.name}
              </Link>
            </Heading>

            <div>
              <p className={'text-xs text-gray-400 dark:text-gray-500'}>
                Due {getTimeAgo(new Date(task.dueDate))}
              </p>
            </div>
          </div>

          <div className={'flex justify-end'}>
            <Tooltip content={`Delete Task`}>
              <IconButton
                onClick={(e) => {
                  e.stopPropagation();
                  setIsDeleting(true);
                }}
              >
                <TrashIcon className={'h-5 text-red-500'} />
              </IconButton>
            </Tooltip>
          </div>
        </div>
      </div>

      <ConfirmDeleteTaskModal
        isOpen={isDeleting}
        setIsOpen={setIsDeleting}
        task={task.name}
        onConfirm={onDelete}
      />
    </>
  );
};

export default TaskListItem;

function useTimeAgo() {
  return useCallback((date: Date) => {
    return formatDistance(date, new Date(), {
      addSuffix: true,
    });
  }, []);
}

Form Submission

Now that we have created a custom hook to write data to Firestore, we need to build a form to create a new task.

The example below is straightforward:

  1. We have a form with two input fields
  2. When the form is submitted, we read the data using FormData
  3. Finally, we add the document using the callback returned by the hook useCreateTask
import { useNavigate } from '@remix-run/react';
import type { FormEventHandler } from 'react';
import { useCallback } from 'react';
import toaster from 'react-hot-toast';

import TextField from '~/core/ui/TextField';
import Button from '~/core/ui/Button';
import useCreateTaskMutation from '~/lib/tasks/hooks/use-create-task';

import useCurrentOrganization from '~/lib/organizations/hooks/use-current-organization';

const CreateTaskForm = () => {
  const createTaskMutation = useCreateTaskMutation();
  const navigate = useNavigate();
  const organization = useCurrentOrganization();
  const organizationId = organization?.id as number;

  const onCreateTask: FormEventHandler<HTMLFormElement> = useCallback(
    async (event) => {
      event.preventDefault();

      const target = event.currentTarget;
      const data = new FormData(target);
      const name = data.get('name') as string;
      const dueDate = (data.get('dueDate') as string) || getDefaultDueDate();

      if (name.trim().length < 3) {
        toaster.error('Task name must be at least 3 characters long');

        return;
      }

      const task = {
        organizationId,
        name,
        dueDate,
        done: false,
      };

      // create task
      await createTaskMutation.mutateAsync(task);

      // redirect to /tasks
      return navigate(`/tasks`);
    },
    [navigate, createTaskMutation, organizationId]
  );

  return (
    <form onSubmit={onCreateTask}>
      <div>
        <TextField.Label>
          Name
          <TextField.Input
            required
            name={'name'}
            placeholder={'ex. Launch on IndieHackers'}
          />
          <TextField.Hint>Hint: whatever you do, ship!</TextField.Hint>
        </TextField.Label>

        <TextField.Label>
          Due date
          <TextField.Input name={'dueDate'} type={'date'} />
        </TextField.Label>

        <div>
          <Button>Create Task</Button>
        </div>
      </div>
    </form>
  );
};

export default CreateTaskForm;

function getDefaultDueDate() {
  const date = new Date();
  date.setDate(date.getDate() + 1);
  date.setHours(23, 59, 59);

  return date.toDateString();
}

Writing Application Pages

Now that we have some components to display, we need to add them to the actual Remix pages.

If you have not created the files in the Routing section above, it's time to do it.

Using the components AppHeader and AppContainer

These components are used to wrap the content of the pages. They are responsible for displaying the header and the container of the page.

function DashboardPage() {
  return (
    <>
      <AppHeader>
        <span className={'flex space-x-2'}>
          <Squares2X2Icon className="w-6" />

          <span>
            <Trans i18nKey={'common:dashboardTabLabel'} />
          </span>
        </span>
      </AppHeader>

      <AppContainer>
        Your content goes here
      </AppContainer>
    </>
  );
}

API Routes

Makerkit provides some utilities to reduce the boilerplate needed to write Remix API functions. This section will teach you everything you need to know to write your API functions.

Calling API functions from the client

API functions in Remix are defined using the special functions loader and action.

These functions are defined in the routes. These functions can be called in various ways, for example using Remix's useFetcher, useSubmit, <Form>, or can be called using simple fetch calls.

For example, the following code calls the action function owner from the file routes/api/organizations/[id]/owner.ts.

First, we define a function using the utility useFetch and React Query's useMutation hook.

import useFetch from '~/core/hooks/use-fetch';
import { useMutation } from '@tanstack/react-query';

function useTransferOrganizationOwnership() {
  const transferOrganizationFetch = useFetch<{ membershipId: number }>(
    `/settings/organization/members/owner`,
    'PUT'
  );

  return useMutation((membershipId: number) => {
    return transferOrganizationFetch({ membershipId });
  });
}

export default useTransferOrganizationOwnership;

Then, we can define an action function in the file routes/api/organizations/[id]/owner.ts.

export async function action(args: ActionArgs) {
  const req = args.request;

  // logic to transfer ownership
}

Sending the CSRF Token

When you use the hook useFetch, you don't need to worry about sending the CSRF token. The hook will automatically send the token.

Instead, if you use fetch directly, you need to send the CSRF token. To retrieve the page's CSRF token, you can use the useGetCsrfToken hook:

const getCsrfToken = useGetCsrfToken();
const csrfToken = getCsrfToken();

console.log(csrfToken) // token

You will need to send a header x-csrf-token with the value returned by getCsrfToken().

CSRF Token check

We must pass a CSRF token when using POST requests to prevent malicious attacks. To do so, we use the pipe withCsrfToken.

  1. The CSRF token is generated when the page is server-rendered
  2. The CSRF token is stored in a meta tag
  3. The CSRF token is sent to an HTTP POST request automatically when using the useFetch hook
  4. This function will throw an error when the token is invalid

By using this function, we ensure all the following functions will not be executed unless the token is valid.

export const action: ActionFunction = async ({ request }) => {
  await withCsrf(request);
}

API Payload Validation

Validating payloads is necessary to ensure your API endpoints receive the expected data. To validate the API, we use Zod.

Zod is a Typescript library that helps us secure our API endpoints by validating the payloads sent from the client and also facilitating the typing of the payload with Typescript.

Using Zod is the first line of defense to validate the data sent against our API: as a result, it's something we recommend you keep doing. It ensures we write safe, resilient, and valid code.

When we write an API endpoint, we first define the schema of the payload:

function getBodySchema() {
  return z.object({
    displayName: z.string(),
    email: z.string().email(),
  });
}

This function represents the schema, which will validate the following interface:

interface Body {
  displayName: string;
  email: Email;
}

Now, let's write the body of the API handler that validates the body of the function, which we expect to be equal to the Body interface.

import { throwBadRequestException } from `~/core/http-exceptions`;

export const action: ActionFunction = async ({ request }) => {
  try {
     // we can safely use data with the interface Body
    const schema = getBodySchema();
    const body = await request.json();
    const { displayName, email } = schema.parse(body);

    return sendInvite({ displayName, email });
  } catch(e) {
    return throwBadRequestException();
  }
}

I encourage you to never skip the validation step when writing your API endpoints.

API Logging

The boilerplate uses Pino for API logging, a lightweight logging utility for Node.js.

Logging is necessary to debug your applications and understand what happens when things don't behave as expected. You will find various instances of logging throughout the API, but we encourage you to log more if necessary.

We import the logging utility from ~/core/logger. Typically, you can log every time you perform an action, both before and after. For example:

async function myFunction(params: {
  organizationId: string;
  userId: string;
}) {
  logger.info(
    {
      organizationId: params.organizationId,
      userId: params.userId,
    },
    `Performing action...`
  );

  await performAction();

  logger.info(
    {
      organizationId: params.organizationId,
      userId: params.userId,
    },
    `Action successful`
  );
}

It's always important to add context to your logs: as you can see, we use the first parameter to add important information.

i18n: Translating the Remix application into multiple languages

Most strings in the Makerkit template's application use remix-i18next, a library to translate your application into multiple languages. Thanks to this library, we can store all the application's text in json files for each supported language.

For example, if you are serving your application in English and Spanish, you'd have the following files:

  • English translation files at /public/locales/en/{file}.json
  • Spanish translation files at /public/locales/es/{file}.json

There are valid reasons to use translation files, even if you are not planning on translating your application, such as:

  1. it's easier to search and replace text
  2. tidier render functions
  3. easy update path if you do decide to support a new language

Adding new languages

By default, Makerkit uses English for translating the website's text. All the files are stored in the files at /public/locales/en.

Adding a new language is very simple:

  1. Translation Files: First, we need to create a new folder, such as /public/locales/es, and then copy over the files from the English version and start translating files.
  2. Remix i18n config: We need to also add a new language to the Remix configuration at app/i18n/i18next.config.ts. Simply add your new language's code to the supportedLanguages array.

The configuration will look like the below:

const i18Config = {
  fallbackLanguage: DEFAULT_LOCALE,
  supportedLanguages: [DEFAULT_LOCALE, 'es'],
  defaultNS: ['common', 'auth', 'organization', 'profile', 'subscription'],
  react: { useSuspense: false },
};

Setting the default Locale

To set the default locale, simply update the environment variable DEFAULT_LOCALE stored in .env.

So, open the .env file, and update the variable:

DEFAULT_LOCALE=de

Updating the Marketing Website

Adding pages to the Navigation Menu

To update the site's navigation menu, you can visit the SiteNavigation.tsx component and add a new entry to the menu.

By default, you will see the following object links:

const links: Record<string, Link> = {
  Blog: {
    label: 'Blog',
    path: '/blog',
  },
  Docs: {
    label: 'Docs',
    path: '/docs',
  },
  Pricing: {
    label: 'Pricing',
    path: '/pricing',
  },
  FAQ: {
    label: 'FAQ',
    path: '/faq',
  },
};

The menu is defined in the render function:

<NavigationMenu>
  <NavigationMenuItem link={links.Blog} />
  <NavigationMenuItem link={links.Docs} />
  <NavigationMenuItem link={links.Pricing} />
  <NavigationMenuItem link={links.FAQ} />
</NavigationMenu>

Assuming we want to add a new menu entry, say About, we would first add the link object:

About: {
  label: 'About',
  path: '/about',
},

And then we update the menu:

<NavigationMenu>
  <NavigationMenuItem link={links.About} />
  ...
</NavigationMenu>

Updating your Project

If you have followed the steps to set up Git at the beginning of this tutorial, you've already set the original Makerkit repository as upstream.

As the repository is constantly updated, it's recommended to fetch updates regularly. You can do so by running the following Git command:

git pull upstream main --allow-unrelated-histories

Unfortunately, you may need to resolve eventual conflicts.

Any questions?

If you have questions, please join our Discord Community, even if you have not purchased any kit. We chat and help each other build products.


Stay informed with our latest resources for building a SaaS

Subscribe to our newsletter to receive updatesor