This tutorial is a comprehensive guide to getting started with the Next.js and Firebase 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:
- Landing Page and Marketing pages (blog, documentation)
- Authentication (sign in, sign up)
- Payments (Stripe)
- Profile and Organization management
Prerequisites
To get started, you're going to need some things installed:
- Git
- Node.js version
- npm 7 or greater
- A code editor (VSCode, WebStorm)
- If you'd like to deploy your application, you'll also want an account on Vercel (it's free and easy to use)
Experience with React, TypeScript/JavaScript, and Firebase 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 https://github.com/makerkit/next-firebase-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
:
git remote rm origin
git remote add upstream https://github.com/makerkit/next-firebase-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
Perfect! Now you can fire up your IDE and open the tasks-app
project we just created.
Project Structure
When inspecting the project structure, you will find something similar to the below:
tasks-app
├── README.md
├── @types
├── src
│ ├── components
│ ├── core
│ ├── lib
│ └── pages
└── _document.tsx
└── _app.tsx
│ └── index.tsx
├── package-lock.json
├── package.json
├── public
│ └── favicon.ico
├── next.config.mjs
└── 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 thetasks
components tosrc/components/tasks
.src/lib
: this folder contains the business logic of your application's domain. For example, we can add thetasks
hooks to write and fetch data from Firestore insrc/lib/tasks
.src/pages
: this is Next.js's special 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:
- Next.js: First, we need to run the Next.js development server
- Firebase: We need to run the Firebase Emulators. The emulators allow us to run a fully-local Firebase environment.
- 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 Next.js development server
Run the following command from your IDE or from your terminal:
npm run dev
This command will start the Next.js server at localhost:3000. If you navigate to this page, you should be able to see the landing page of your application:

Running the Firebase Emulators
To interact with Firebase's services, such as Auth, Firestore, and Storage, we need to run the Firebase emulators. To do so, open a new terminal (or, better, from your IDE) and run the following command:
npm run firebase:emulators:start
If everything is working correctly, you will see the output below:
┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator │ Host:Port │ View in Emulator UI │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore │ localhost:8080 │ http://localhost:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Pub/Sub │ localhost:8085 │ n/a │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Storage │ localhost:9199 │ http://localhost:4000/storage │
└────────────────┴────────────────┴─────────────────────────────────┘
Emulator Hub running at localhost:4400
Other reserved ports: 4500, 9150
By default, we run the emulator for the following services: Authentication, Firestore, and Storage. However, you can easily add Firebase Functions and PubSub.
Firebase Emulator UI
As you can see from the View in Emulator UI
column, you have access to the Emulator UI, which helps you see and edit your Firebase Emulator data using a nifty UI that resembles the Firebase Console.
For example, to display the list of users that have signed up, navigate to the Authentication Emulator.
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 Firestore and Authentication tabs.
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, such as the Firebase configuration.
For example, in your Vercel console, you could run multiple projects:
- one for
production
- and one for
staging
Here is what the configuration file looks like:
const configuration = {
site: {
name: 'Awesomely - Your SaaS Title',
description: 'Your SaaS Description',
themeColor: '#ffffff',
themeColorDark: '#0a0a0a',
siteUrl: process.env.NEXT_PUBLIC_SITE_URL as string,
siteName: 'Awesomely',
twitterHandle: '',
githubHandle: '',
language: 'en',
convertKitFormId: '',
},
firebase: {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
},
auth: {
// Enable MFA. You must upgrade to GCP Identity Platform to use it.
// see: https://cloud.google.com/identity-platform/docs/product-comparison
enableMultiFactorAuth: false,
// NB: Enable the providers below in the Firebase Console
// in your production project
providers: {
emailPassword: true,
phoneNumber: false,
emailLink: false,
oAuth: [GoogleAuthProvider],
},
},
emulatorHost: process.env.NEXT_PUBLIC_EMULATOR_HOST,
emulator: process.env.NEXT_PUBLIC_EMULATOR === 'true',
production: process.env.NODE_ENV === 'production',
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: `/api/stripe/checkout`,
billingPortal: `/api/stripe/portal`,
},
searchIndex: `/public/search-index`,
},
navigation: {
style: LayoutStyle.Sidebar,
},
appCheckSiteKey: process.env.NEXT_PUBLIC_APPCHECK_KEY,
email: {
host: '',
port: 0,
user: '',
password: '',
senderAddress: '',
},
sentry: {
dsn: process.env.SENTRY_DSN,
},
stripe: {
plans: [
{
name: 'Basic',
description: 'Description of your Basic plan',
price: '$249/year',
stripePriceId: 'basic-plan',
features: [
'Feature 1',
'Feature 2',
'common:plans.features.feature1'
],
},
],
}
};
Environment Variables
The starter project comes with three different environment variables files:
- .env: this is a shared environment file. Here, you can add variables shared across all the environments.
- .env.development: the configuration file loaded when running the
dev
command: by default, we add the Firebase Emulators settings to this file.
- Use this file for your development configuration.
- .env.production: the configuration file loaded when running the
build
command locally or deployed to Vercel.
- Use this file for your actual project's configuration (without private keys!).
- .env.test: this environment file is loaded when running the Cypress E2E tests. You would rarely need to use this.
Additionally, you can add another environment file named .env.local
: this file is never added to Git and can be used to store the secrets you need during development.
Never ever add secrets to your .env
files. It's not safe, and you risk leaking confidential data.
Instead, add your secrets using your Vercel Console, or use the .env.local
file during development.
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:
- choose another color from the Tailwind CSS Color palette (for example, try
colors.indigo
, see the image below) - define your own colors, from
50
to900
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
component in _document.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: ['./src/**/*.tsx'],
darkMode: 'media',
// ...
}
Authentication
The Next.js/Firebase template uses Firebase Auth to manage authentication into the internal application.
The kit supports the following strategies:
- Email/Password
- oAuth Providers such as Google, Twitter, Facebook, Microsoft, Apple, etc.
- Phone Number
- Email Link
You can choose one, more, or all of them together, and you can easily tweak this using the global configuration.
By default, our 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 Firestore in the
getServerSideProps
function
How does SSR work?
To enable SSR with Firebase Auth, we use Session Cookies.
- First, we use the Firebase Auth Client SDK to sign users in, as described in every tutorial. Furthermore, when a user signs in or signs up, we retrieve the ID Token and make a request to our API to create a session cookie. The session cookie remains valid for 14 days, after which it will expire.
- The cookie
sessionId
gets stored as an HTTP-only cookie: we can use this server-side for fetching the current user. - When the token expires, we sign users out of the application
So... how do they work?
- The client SDK uses the Firebase Auth credentials stored in the IndexedDB Storage. This includes interacting with Firebase Storage and Firebase Firestore on the client side.
- The server-side API uses the
sessionId
cookie to retrieve and authenticate the current user. We can then use the Firebase Admin API and interact with the various Firebase services.
Authentication Strategies
To add or tweak the authentication strategies of our application, we can update the configuration:
auth: {
// Enable MFA. You must upgrade to GCP Identity Platform to use it.
// see: https://cloud.google.com/identity-platform/docs/product-comparison
enableMultiFactorAuth: false,
// NB: Enable the providers below in the Firebase Console
// in your production project
providers: {
emailPassword: true,
phoneNumber: false,
emailLink: false,
oAuth: [GoogleAuthProvider],
},
},
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:
import { GoogleAuthProvider, TwitterAuthProvider } from 'firebase/auth';
auth: {
// Enable MFA. You must upgrade to GCP Identity Platform to use it.
// see: https://cloud.google.com/identity-platform/docs/product-comparison
enableMultiFactorAuth: false,
// 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: [GoogleAuthProvider, TwitterAuthProvider],
},
},
And the result will be similar to the image below:

Custom oAuth providers
Additionally, we can add custom oAuth providers, such as Microsoft
and Apple
:
class MicrosoftAuthProvider extends OAuthProvider {
constructor() {
super('microsoft.com');
}
}
class AppleAuthProvider extends OAuthProvider {
constructor() {
super('apple.com');
}
}
Then, you will add these classes to the auth.providers.oAuth
object of the configuration.
Remember that you will always need to enable the authentication methods you want to use from the Firebase Console once you deploy your application to production
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:
- create the user Firestore record at
/users
- create the organization Firestore record at
/organizations
and assign the user as its owner
I encourage you to visit the Firestore Emulator UI 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:
- create new Organizations
- be invited to other Organizations
- 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.
Page Structure
Before getting started, let's take a look at the default page structure of the boilerplate is the following.
├── pages
└── api
└── onboarding
└── organizations
└── stripe
└── session
└── user
└── auth
└── invite
└── [code].tsx
└── link.tsx
└── password-reset.tsx
└── sign-in.tsx
└── sign-up.tsx
└── dashboard
└── index.tsx
└── onboarding
└── index.tsx
└── settings
└── organization
└── members
└── index.tsx
└── invite.tsx
└── profile
└── index.tsx
└── email.tsx
└── password.tsx
└── authentication.tsx
└── subscription
└── blog
└── [collection]
└── [slug].tsx
└── docs
└── [page]
└── slug.tsx
└── index.tsx
└── 500.tsx
└── 404.tsx
└── _document.tsx
└── _app.tsx
└── index.tsx
└── faq.tsx
└── pricing.tsx
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/pages/dashboard/index.tsx
.
You can update the above by setting the application's home page path at configuration.paths.appHome
. The configuration file can be found at src/configuration.ts
.
Routing
Ok, so we want to add three pages to our application:
- List: A page to list all our tasks
- New Task: Another page to create a new task
- Task: A page specific to the selected task
To create these two pages, we will create a folder named tasks
at src/pages/tasks.
In the folder src/pages/tasks
we will create three Page Components
:
- List Page: we create a page
index.tsx
, which is accessible at the path/tasks
- New Task Page: we create a page
new.tsx
, which is accessible at the path/tasks/new
- Task Page: we create a page
[id].tsx
, which is accessible at the path/tasks/<taskID>
wheretaskID
is a dynamic variable that refers to the actual ID of the task
├── pages
└── 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:
- Add a new page, and the links to it
- Add the components of the domain and import them into the pages
- 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:
- First, we want to define our data model
- 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
- Then, we import and use our hooks within the components
- Finally, we add the components to the pages
Adding page links to the Navigation Menu
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} />;
},
},
Now, also add the common:tasksTabLabel
key to the public/locales/en/common.json
file:
{
"tasksTabLabel": "Tasks"
}
Save the file and restart the Next server by rerunning the npm run dev
command.
The result will be similar to the images below:
Sidebar Menu Layout

Header Menu Layout

Fetching data from Firestore
Before writing the Components, we want to tackle the task of fetching data from Firestore. This involves a couple of steps:
- We need to define, on paper, what the data model of the Firestore collections looks like
- We need to update the Firestore rules so we can read and write data to the collection
- We need to write the hooks to fetch data from Firebase Firestore
Firestore Data Model
First, let's write the data model. But... what does it mean?
When thinking about the Firestore data model, we need to answer the following questions:
- Where does the data live?
- How is the data structured?
- How do we protect the data with security rules?
Ok, let's reply to these questions.
Where does the data live?
We can place tasks
as a Firestore top-level collection. Because tasks belong to an organization, we can ensure that only users of that organization can read and write those tasks by adding a foreign key to each task
named organizationId
.
How is the data structured?
We can define a Task
model at src/lib/tasks/types/task.ts
:
export interface Task {
name: string;
description: string;
organizationId: string;
dueDate: string;
done: boolean;
}
How do we protect the data with security rules?
To write our Security Rules, we will update the file firestore.rules
:
match /tasks/{taskId} {
allow create: if userIsMemberByOrganizationId(incomingData().organizationId);
allow read, update, delete: if userIsMemberByOrganizationId(existingData().organizationId);
}
The function userIsMemberByOrganizationId
is pre-defined in the Makerkit's template: basically, it will verify that the current user is a member of the organization with ID organizationId
.
If you use VSCode, take a look at the extension toba.vsfire
.
Data Fetching: React Hooks to read data from Firestore
Now that our security rules are updated, we can write Hooks to read data from Firestore.
I recommend writing your entities' hooks at src/lib/tasks/hooks
. Let's start with a React Hook
to fetch all the organization's tasks:
import { useFirestore, useFirestoreCollectionData } from 'reactfire';
import {
collection,
CollectionReference,
query,
where,
} from 'firebase/firestore';
import { Task } from '~/lib/tasks/types/task';
function useFetchTasks(organizationId: string) {
const firestore = useFirestore();
const tasksCollection = 'tasks';
const collectionRef = collection(
firestore,
tasksCollection
) as CollectionReference<WithId<Task>>;
const path = `organizationId`;
const operator = '==';
const constraint = where(path, operator, organizationId);
const organizationsQuery = query(collectionRef, constraint);
return useFirestoreCollectionData(organizationsQuery, {
idField: 'id',
});
}
export default useFetchTasks;
Let's take a deep breath and slowly go through the above hook.
- The hook above uses
useFirestoreCollectionData
, aReact hook
from the libraryreactfire
to fetch real-time collection data from Firestore - We build a query that checks that the
organizationId
parameter matches theorganizationId
property of each task - Finally, we add
{ idField: 'id' }
. This special parameter decorates the data with the ID of the document. Thanks to the interfaceWithId
, we add anid
property to theTask
interface returned from Firestore
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 { Task } from '~/lib/tasks/types/task';
const TasksContainer: React.FC<{
organizationId: string;
}> = ({ organizationId }) => {
const { status, data: tasks } = useFetchTasks(organizationId);
if (status === `loading`) {
return <PageLoadingIndicator>Loading Tasks...</PageLoadingIndicator>;
}
if (status === `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>
<div className={'flex flex-col space-y-4'}>
{tasks.map((task) => {
return <TaskListItem task={task} key={task.id} />;
})}
</div>
</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;
The data above will automatically update when the tasks
collection changes!
Since the component "TaskListItem" 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 Firestore
While reactfire
does not provide hooks for writing data to Firestore, we can still make our own.
In fact, I encourage you to make a custom hook for each mutation.
Creating a Task
In the example below, we write a custom hook to add a document to the tasks
collection:
import { useFirestore } from 'reactfire';
import { useCallback } from 'react';
import { addDoc, collection } from 'firebase/firestore';
import { Task } from '~/lib/tasks/types/task';
function useCreateTask() {
const firestore = useFirestore();
const tasksCollection = collection(firestore, `/tasks`);
return useCallback(
(task: Task) => {
return addDoc(tasksCollection, task);
},
[tasksCollection]
);
}
export default useCreateTask;
The hook above returns a callback. Let's take a quick look at its usage:
import useCreateTask from '~/lib/tasks/hooks/use-create-task';
function Component() {
const createTask = useCreateTask();
return <Form onCreate={task => createTask(task)} />
}
Updating a Task
Now, let's write a hook to update an existing task.
To update Firestore documents, we will import the function updateDoc
from Firestore.
import { useCallback } from 'react';
import { useFirestore } from 'reactfire';
import { doc, updateDoc } from 'firebase/firestore';
import { Task } from '~/lib/tasks/types/task';
function useUpdateTask(taskId: string) {
const firestore = useFirestore();
const tasksCollection = 'tasks';
const docRef = doc(firestore, tasksCollection, taskId);
return useCallback(
(task: Partial<Task>) => {
return updateDoc(docRef, task);
},
[docRef]
);
}
export default useUpdateTask;
Deleting a Task
Finally, we write a mutation to delete a task:
import { useFirestore } from 'reactfire';
import { deleteDoc, doc } from 'firebase/firestore';
import { useCallback } from 'react';
function useDeleteTask(taskId: string) {
const firestore = useFirestore();
const collection = `tasks`;
const task = doc(firestore, collection, taskId);
return useCallback(() => {
return deleteDoc(task);
}, [task]);
}
export default useDeleteTask;
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 { MouseEventHandler, useCallback, useState } from 'react';
import Link from 'next/link';
import { TrashIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { Task } from '~/lib/tasks/types/task';
import Heading from '~/core/ui/Heading';
import useTimeAgo from '~/core/hooks/use-time-ago';
import IconButton from '~/core/ui/IconButton';
import useDeleteTask from '~/lib/tasks/hooks/use-delete-task';
import ConfirmDeleteTaskModal from '~/components/tasks/ConfirmDeleteTaskModal';
import useUpdateTask from '~/lib/tasks/hooks/use-update-task';
import { Tooltip, TooltipTrigger, TooltipContent } from '~/core/ui/Tooltip';
const TaskListItem: React.FC<{
task: WithId<Task>;
}> = ({ task }) => {
const getTimeAgo = useTimeAgo();
const deleteTask = useDeleteTask(task.id);
const updateTask = useUpdateTask(task.id);
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = useCallback(() => {
return toast.promise(deleteTask(), {
success: `Task deleted!`,
loading: `Deleting task...`,
error: `Ops, error! We could not delete task`,
});
}, [deleteTask]);
const onDoneChange = useCallback(
(done: boolean) => {
const promise = updateTask({ done });
return toast.promise(promise, {
success: `Task updated!`,
loading: `Updating task...`,
error: `Ops, error! We could not update task`,
});
},
[updateTask]
);
const onDeleteClick: MouseEventHandler = useCallback((e) => {
e.stopPropagation();
setIsDeleting(true);
}, []);
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'}
href={`/tasks/[id]`}
as={`/tasks/${task.id}`}
passHref
>
{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>
<TooltipTrigger asChild>
<IconButton onClick={onDeleteClick}>
<TrashIcon className={'h-5 text-red-500'} />
</IconButton>
</TooltipTrigger>
<TooltipContent>Delete Task</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<ConfirmDeleteTaskModal
isOpen={isDeleting}
setIsOpen={setIsDeleting}
task={task.name}
onConfirm={onDelete}
/>
</>
);
};
export default TaskListItem;
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:
- We have a form with two input fields
- When the form is submitted, we read the data using
FormData
- Finally, we add the document using the callback returned by the hook
useCreateTask
import { useRouter } from 'next/router';
import { FormEventHandler, useCallback } from 'react';
import TextField from '~/core/ui/TextField';
import Button from '~/core/ui/Button';
import useCreateTask from '~/lib/tasks/hooks/use-create-task';
import { useCurrentOrganization } from '~/lib/organizations/hooks/use-current-organization';
const CreateTaskForm = () => {
const createTask = useCreateTask();
const router = useRouter();
const organization = useCurrentOrganization();
const organizationId = organization?.id as string;
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;
const task = {
organizationId,
name,
dueDate,
done: false,
};
// create task
await createTask(task);
// redirect to /tasks
await router.push(`/tasks`);
},
[router, createTask, organizationId, setLoading]
);
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 required name={'dueDate'} type={'date'} />
</TextField.Label>
<div>
<Button>
Create Task
</Button>
</div>
</div>
</form>
);
};
export default CreateTaskForm;
Writing Application Pages
Now that we have some components to display, we need to add them to the actual Next.js pages.
If you have not created the files in the Routing
section above, it's time to do it.
Using the RouteShell component
The RouteShell component is a wrapper around your internal application pages. This component will:
- Automatically protect pages when the user is not authenticated
- Initializes the Firestore provider on the page
- Wraps the page with one of the two layouts available:
Sidebar
orTop Header
const TaskPage: React.FC<{ taskId: string }> = ({ taskId }) => {
return (
// Type 'Element' is not assignable to type 'string’
<RouteShell title={<TaskPageHeading />}>
<ErrorBoundary
fallback={<Alert type={'error'}>Ops, an error occurred :</Alert>}
>
<TaskItemContainer taskId={taskId} />
</ErrorBoundary>
</RouteShell>
);
};
export default TaskPage;
Protecting Internal Pages with Route Guards
When we create an internal page that should be protected using authentication, we need to use a Makerkit function named withAppProps
.
This function is a server-side props function
that takes the context ctx
as a parameter:
import RouteShell from `~/components/RouteShell`;
import { withAppProps } from '~/lib/props/with-app-props';
const TaskPage: React.FC<{ taskId: string }> = ({ taskId }) => {
return (
// Type 'Element' is not assignable to type 'string’
<RouteShell title={<TaskPageHeading />}>
<ErrorBoundary
fallback={<Alert type={'error'}>Ops, an error occurred :</Alert>}
>
<TaskItemContainer taskId={taskId} />
</ErrorBoundary>
</RouteShell>
);
};
export function getServerSideProps(ctx: GetServerSidePropsContext) {
return withAppProps(ctx);
}
The withAppProps
function will:
- return the current user data (both Auth and Firestore)
- return the selected user's authentication
- return the page's translations
- return a CSRF token
interface PageProps {
session?: Maybe<AuthUser>;
user?: Maybe<UserData>;
organization?: Maybe<WithId<Organization>>;
csrfToken?: string;
ui?: UIState;
}
Additionally, it will validate the current session:
- Is the user onboarded? Otherwise, redirect to the onboarding flow
- Does the user exist? Otherwise, redirect the user back to the login page
- Is the session cookie valid? Otherwise, redirect the user back to the login page
You can extend this function and add any additional validation you deem needed.
API Routes
Makerkit provides some utilities to reduce the boilerplate needed to write Next.js API functions. This section will teach you everything you need to know to write your API functions.
As you may know, we write Next.js' API functions at pages/api
. Therefore, every file listed in this folder becomes a callable HTTP function.
For example, we can write the file pages/api/hello-world.ts
:
export default function helloWorldHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
res.send(`Hello World`);
}
This API endpoint will simply return Hello World
.
Calling API functions from the client
To make requests to the API functions in the api
folder, we provide a
custom hook useApiRequest
, a wrapper around fetch
.
It has the following functionality:
- Uses an internal
state
to keep track of the state of the request, likeloading
,error
andsuccess
. This helps you write fetch requests the "hooks way" - Automatically adds a
Firebase AppCheck Token
to the request headers if you enabled Firebase AppCheck - Automatically adds a
CSRF token
to the request headers
Similarly to making Firestore Requests, it's a convention to create a custom hook for each request for readability/reusability reasons.
import { useApiRequest } from '~/core/hooks/use-api';
interface Body {
idToken: string;
}
export function useCreateSession() {
return useApiRequest<void, Body>('/api/session/sign-in');
}
The hook returns an array:
- Callback: the first element is the callback to make the request
- State: the second element is the state object of the request
const [createSession, createSessionState] = useCreateSession();
The state object internally uses useApiRequest
, and has the following
interface:
{
success: boolean;
loading: boolean;
error: string;
data: T | undefined;
}
When success
is true
, the property data
is inferred with its correct type.
You can use this hook in your components:
import { useCreateSession } from '~/core/hooks/use-create-session';
function Component() {
const [createSession, createSessionState] = useCreateSession();
return (
<>
{ createSessionState.loading ? `Loading...` : null }
{ createSessionState.error ? `Error :(` : null }
{ createSessionState.success ? `Yay, success!` : null }
<SignInForm onSignIn={(idToken) => createSession({ idToken })} />
</>
);
}
Similarly, you can also use it to fetch data:
import { useCreateSession } from '~/core/hooks/use-create-session';
function Component() {
const [fetchMembers, { loading, error, data }]
= useFetchMembers();
// fetch data when the component mounts
useEffect(() => {
fetchMembers();
}, [fetchMembers]);
if (loading) {
return <div>Fetching members...</div>;
}
if (error) {
return <div>{error}</div>;
}
return (
<div>
{data.map(member => <MemberItem member={member} />)}
</div>
);
}
Writing your own Fetch Hook
You are free to use your own implementation for sending HTTP requests to your API or use a powerful third-party library.
With that said, you need to ensure that you're sending the required headers:
- The AppCheck token (if you use Firebase AppCheck)
- The CSRF Token (for POST requests)
Generating an App Check Token
To generate an App Check token, you can use the useGetAppCheckToken
hook:
const getAppCheckToken = useGetAppCheckToken();
const appCheckToken = await getAppCheckToken();
console.log(appCheckToken) // token
You will need to send a header X-Firebase-AppCheck
with the resolved value returned by the promise getAppCheckToken()
.
Sending 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()
.
Piping utilities
Makerkit uses a dead-simple utility to pipe functions named withPipe
. We can pass any function
to this utility, which will iterate over its parameters and execute each.
Each function must be a Next.js handler that accepts the req
and res
objects. Let's take a look at the below:
export default function members(req: NextApiRequest, res: NextApiResponse) {
const handler = withPipe(
withMethodsGuard(['POST']),
withAuthedUser,
withAppCheck,
membersHandler
);
return withExceptionFilter(req, res)(handler);
}
What happens? We've passed 4 functions to the withPipe
utility. The utility iterates over each of them. So it will execute withMethodsGuard
, withAuthedUser
, withAppCheck
, and if everything went well during the checks, it will run the API logic we define in membersHandler
.
withMethodsGuard
checks if the method passed is supportedwithAuthedUser
checks if the user is authenticatedwithAppCheck
executes a Firebase App Check validation to prevent abusemembersHandler
is the actual logic of the API endpoint
API Authentication
To validate that API functions are called by users authenticated with Firebase Auth, we use withAuthedUser
.
This function will:
- Initialize the Firebase Admin
- Get the user using the
session
cookie - Throw an error when not authenticated
By using this function, we ensure all the following functions will not be executed unless the user is authenticated.
export default function members(req: NextApiRequest, res: NextApiResponse) {
const handler = withPipe(
withAuthedUser,
membersHandler
);
return withExceptionFilter(req, res)(handler);
}
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
.
- The CSRF token is generated when the page is server-rendered
- The CSRF token is stored in a
meta
tag - The CSRF token is sent to an HTTP POST request automatically when using the
useApiRequest
hook - 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 default function members(req: NextApiRequest, res: NextApiResponse) {
const handler = withPipe(
withAuthedUser,
withCsrfToken,
membersHandler
);
return withExceptionFilter(req, res)(handler);
}
Firebase App Check
App Check is a Firebase service that helps you to protect your web app from bots, spammy users, and general abuse detected by Google's Recaptcha.
Check out the documentation to setup Firebase App Check.
Using the withAppCheck
pipe, we ensure all the following functions will not be executed unless the App Check token is valid.
export default function members(req: NextApiRequest, res: NextApiResponse) {
const handler = withPipe(
withAuthedUser,
withCsrfToken,
withAppCheck,
membersHandler
);
return withExceptionFilter(req, res)(handler);
}
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`;
function inviteMemberHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
try {
// we can safely use data with the interface Body
const schema = getBodySchema();
const { displayName, email } = schema.parse(req.body);
return sendInvite({ displayName, email });
} catch(e) {
return throwBadRequestException(res);
}
}
export default function apiHandler() {
const handler = withPipe(
withMethodsGuard(['POST']),
withAuthedUser,
inviteMemberHandler,
);
// manage exceptions
return withExceptionFilter(req, res)(handler);
}
I encourage you to never skip the validation step when writing your API endpoints.
Catching and Handling Exceptions
To catch and gracefully handle API exceptions, we use the function withExceptionFilter
.
As seen from its usage above, we wrap the API function within the utility. When errors are caught, this function will:
- Log and debug the error to the console
- Report the error to Sentry (if configured)
- Return an object stripping the error's data to avoid leaking information
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.
Translating the Next.js application into multiple languages
Most strings in the Makerkit template's application use next-18n
, 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:
- it's easier to search and replace text
- tidier render functions
- 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:
- 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. - Next.js i18n config: We need to also add a new language to the Next.js configuration at
next-i18next.config.js
. Simply add your new language's code to thelocales
array.
The configuration will look like the below:
const config = {
i18n: {
defaultLocale: DEFAULT_LOCALE,
locales: [DEFAULT_LOCALE, 'es'],
},
fallbackLng: {
default: [DEFAULT_LOCALE],
},
localePath: resolve('./public/locales'),
};
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
Using the Language Switcher component
The Language switcher component will automatically retrieve and translate the languages listed in your configuration.
When the user changes language, it will update the URL to reflect the user's preferences and rerender the application with the selected language.
Loading video...
NB: The language selector is not added to the application by default; you will need to import it where you want to place it.
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>
Adding a Blog Post
We can add your website's blog posts within the _posts
folder.
Collections
Before writing a blog post, we have to define the post's collection. We use collections
to organize our blog posts by their category.
For example, your blog could have collections such as changelog, announcements, tutorials, etc.
MakerKit generates every collection with its page, listing all the articles associated with it.
To change a collection page, please change the file src/pages/blog/[collection].ts
.
Adding collections
Let's see how we can define a collection
in Typescript:
interface Collection {
name: string;
// image
logo?: string;
emoji?: string;
}
As you can see, the only required property to create a collection is a name. You can also attach an image or a simple emoji for each collection
.
A collection can be simply the following file:
{
"name":"Changelog"
}
Writing a Blog Post
The interface of a blog post is the following:
interface Post {
title: string;
excerpt: string;
date: string;
live: boolean;
tags?: string[];
coverImage?: string;
ogImage?: {
url: string;
};
author?: {
name: string;
picture: string;
url: string;
};
canonical?: string;
}
These values can be defined in MDX files using the frontmatter
, for example:
---
title: An Awesome Post title
except: "Write here a short description for your blog post"
collection: changelog.json
date: '2022-01-05'
live: true
coverImage: '/assets/images/posts/announcement.webp'
tags:
- changelog
---
Adding Documentation pages
We place the documentation pages within the _docs
folder.
Topics
The pages are defined hierarchically so that you can define your documentation in the following way:
- _docs
- [topic]
- [topic.json]
- [page].mdx
The documentation you're reading, for example, is defined as follows.
The MakerKit kit has a folder called _docs
: in this folder, we have a list of
sub-folders for each topic we are describing.
Each folder has a metadata file named meta.json
:
{
"title": "Blog and Docs",
"position": 2,
"description": "Learn how to configure and write your product's Blog and Documentation"
}
The position
property defines the order of the topics. In
the case above, the topic Blog and Docs
will be the third topic in the list.
Topics Pages
Within each topic, we define the collection pages defined as MDX files. They share the same components as the blog posts' files.
A page is defined as follows:
---
title: Blog
position: 1
---
The position
property defines the order in which the page is listed within
the topic.
Going to Production
Before going to Production, ensure to follow the production checklist below:
- Setting up your Firebase Project
- Enabling the Authentication Providers
- Syncing your Security Rules
- Adding Firestore Indexes. Additionally, ensure your complex queries do not need additional indexes, as the Emulator will not reject those requests.
- Setting up an Email Provider. Remember to update the
configuration.email
object with your Email provider's details and credentials.
Deploying to Vercel
When it's time to deploy your application, connect your repository with Vercel and push your main branch.
Before pushing, ensure your application will build correctly. To do so, you have two useful commands.
1) Healthcheck: it will run linting and type-checking. Suitable for quick feedback.
npm run healthcheck
2) Build: it will run the entire application build. Use this to prevent failed deployments in the Vercel CI.
npm run build
And when you're happy with it, push it to Git:
git push origin main
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.