Next.js is a serverless framework that can be used with any database you prefer, and it's one of the reasons why it's so popular among developers.
Why we use Firestore with Next.js
Firestore is a common choice among Next.js developers due to it being performant, scalable, simple, real-time, offline-first, and with a generous free tier. There's a lot to like.
Firestore is a serverless database that we can use from both the client and the server. Communicating with Firestore right from the browser is likely one of its biggest strengths: you no longer need an API to read and write to your database because it can be accessed directly via the SDK.
The possibility of not having to write an API is a huge time-saver for developers, and further help to front-end developers who want to write full-stack applications from scratch.
If you're wondering how we can keep our database secure despite being used from a client (good question!), you may want to take a look at this guide for writing Security Rules for your Firestore database.
One of the biggest criticisms of Firestore was the fact that it added a significant additional size to client-side bundles due to its heavy SDK.
Fortunately, The newly released version 9 of Firebase made massive improvements in terms of size, which can go even further when using the Lite version.
In this article, I want to introduce you to Firestore using Next.js at the time of writing one of the most popular React frameworks, and the framework we built MakerKit on.
Setting Up Firestore with Next.js
If you haven't yet configured your application, you may want to get started using our article about setting up the Firestore Emulators with Next.js.
We use reactfire
; it's a Typescript library by Firebase designed to help developers use Firebase with any React codebase.
Firestore Provider
We need to initialize Firestore and write a provider to wrap the children that use the Firestore hooks.
The code below is a simplified version of what MakerKit does to initialize a Firestore instance:
import { FirestoreProvider, useFirebaseApp } from 'reactfire';import { useMemo } from 'react';import { enableIndexedDbPersistence, connectFirestoreEmulator, initializeFirestore,} from 'firebase/firestore';import { isBrowser } from '~/core/generic';export default function FirebaseFirestoreProvider({ children, useEmulator,}: React.PropsWithChildren<{ useEmulator?: boolean }>) { const firestore = useFirestore(); // connect to emulator if enabled if (useEmulator) { const host = getFirestoreHost(); const port = Number(getFirestorePort()); try { connectFirestoreEmulator(firestore, host, port); } catch (e) { // this may happen on re-renderings } } const enablePersistence = isBrowser(); // We enable offline capabilities by caching Firestore in IndexedDB // NB: if you don't want to cache results, please remove the next few lines if (enablePersistence) { enableIndexedDbPersistence(firestore) } return <FirestoreProvider sdk={firestore}>{children}</FirestoreProvider>;}function getFirestoreHost() { return process.env.NEXT_PUBLIC_FIREBASE_EMULATOR_HOST ?? 'localhost';}function getFirestorePort() { return process.env.NEXT_PUBLIC_FIRESTORE_EMULATOR_PORT ?? 8080;}function useFirestore() { const app = useFirebaseApp(); return useMemo(() => initializeFirestore(app, {}), [app]);}
If we use the provider above in _app.tsx
, every page can have access to Firestore. Adding the Firebase providers may not be the best solution depending on what your application does and how lightweight you want your pages.
Our first Firestore hook to read a document
The library Reactfire
helps us with writing queries to our Firestore database thanks to a list of predefined React hooks.
For example, one common query in SaaS applications is to fetch the organization/project a user belongs to. In MakerKit, we wrote it similarly to the code below:
import { useFirestore, useFirestoreDocData } from 'reactfire';import { doc, DocumentReference } from 'firebase/firestore';import { Organization } from '~/lib/organizations/types/organization';type Response = Organization & { id: string; };export function useFetchOrganization(organizationId: string) { const firestore = useFirestore(); const organizationsPath = `/organizations`; const ref = doc( firestore, organizationsPath, organizationId ) as DocumentReference<Response>; return useFirestoreDocData(ref, { idField: 'id' });}
We imported the Firestore
instance using the React hook useFirestore
. By using the function doc
we can get a reference to a Firestore document at the path specified. In this case, we're fetching a reference of the path /organizations/${organizationId}
.
Using the hook useFirestoreDocData,
we can directly query and retrieve the document's data.
By adding the property idField
, we're also adding the id
of the document to the data object. Adding the id
field is the reason why we merge the interface of the response with the interface { id: string }
.
Reading a list of documents using Firestore
To read a list of documents from a Firestore collection, we can use the useFirestoreCollectionData
hook.
import { useFirestore, useFirestoreCollectionData } from 'reactfire';import { collection, CollectionReference, where, query } from'firebase/firestore';export function useFetchUserOrganizations(userId: string) { const firestore = useFirestore(); const organizationsPath = `organizations`; const ref = collection( firestore, organizationsPath ) as CollectionReference<Organization>; const userPath = `members.${userId}`; const operator = '!='; // adding the constraint where. This means: retrieve the organizations // where the field members[userId] is not null, eg: it exists const constraint = where(userPath, operator, null); const organizationsQuery = query(ref, constraint); return useFirestoreCollectionData(organizationsQuery);}
Using document queries in Firestore
Reactfire's hooks are async and therefore re-render based on the status of the hook, which can be one of the following: loading
, error
, or success
.
const Organization: React.FC<{ id: string}> = ({ id }) => { const { data: organization, status } = useFetchOrganization(id); // warn users there was an error if (status === 'error') { return <p>Ops! Error encountered while loading organization data...</p> } // show a loading indicator if (status === 'loading') { return <p>Loading organization data...</p> } // all good! display organization data return <OrganizationDetail organization={organization} />;}
Using document writes in Firestore
Unlike queries, Reactfire
does not provide any hook to write to documents.
But we can easily make our own by using useState
(NB: the following is a a simplified example, but you'll get the gist).
import { useCallback, useState } from "react";import { collection, addDoc,} from 'firebase/firestore';export function useCreateOrganization() { const firestore = useFirestore(); const [state, setState] = useState(); const mutation = useCallback(async (organization: Organization) => { setState('loading'); const organizationsPath = `/organizations`; const collectionReference = collection(firestore, organizationsPath); try { await addDoc( collectionReference, organization ); setState('success'); } catch (e) { setState('error'); } }, [firestore]); return [ mutation, state, ] as [ typeof mutation, typeof state, ];}
Using the pattern above, we send the mutation as the first element of the array, and the state of the mutation as the second element.
The pattern can be used as follows:
function OrganizationForm() { const [ createOrganization, createOrganizationState ] = useCreateOrganization(); if (createOrganizationState === 'error') { return <p>Ops, we couldn't create this organization</p>; } if (createOrganizationState === 'loading') { return <p>Creating your organization. Please Wait....</p>; } if (createOrganizationState === 'success') { return <p>Yay! It worked.</p>; } return ( <OrganizationForm onSubmit={createOrganization} /> );}
You can also use the same patterns for deleting or updating a document.
Should you use Firestore with your Next.js app?
OK, we've seen that Firestore is solid. But is it the best choice for your application? Let's talk about this.
Many developers start using Firestore, only to meet certain obstacles along the way: rigidity of the data model, need for full-text search, highly relational data structures, and so on.
The above are all signs that Firestore may not have been the best choice for your application or you as a developer (as you may prefer relational databases, for example).
Below is my non-exhaustive checklist for choosing (or not) Firestore:
- I don't need a full-text search, OR I am ready to integrate and duplicate data using a dedicated search engine (Algolia, ElasticSearch, MeilliSearch, TypeSense, etc.)
- I am OK with duplicating data, and I know how and where to do so
- I don't need to make a large number of reads, or I am able to load documents progressively (for example, using pagination)
- I need/want real-time updates
- I need/want offline persistence
Final Words
Firestore is a robust database: easy to start with, secure, and scale. But, at the same time, it's not always the correct choice, so it's worth remembering if you're embarking on starting a SaaS using it.
For example, MakerKit uses Firestore extensively: while it's OK for most applications, it won't be the perfect choice for several use-cases, and that's fine.
If you need any help or want to know more about how we use Firestore in MakerKit, please reach out and let's talk.