Doğacan Bilgili A software developer who is also into 3D-modeling and animation.

Building a full-stack app with Next.js and Firebase’s Cloud Firestore

12 min read 3482

Full-Stack App Next.js Firestone Cloud Firebase

Editor’s note: This tutorial was updated on 8 March 2022 to include the latest information for Firebase’s Cloud Firestore.

Setting up databases has never been easier. What used to take a few hours can now be done in a matter of minutes. In particular, with Firebase’s newest NoSQL database, developers can have a database up and running in a couple of clicks. Like Firestore’s Realtime Database, Cloud Firestore is free and allows developers to store, edit, and sync data at a global scale for mobile and web applications. Combined with a web or mobile client, developers can create complete web solutions quickly and easily.

In this tutorial, we’ll demonstrate how to create a full-stack application using Next.js and Cloud Firestore. We’ll review how to set up a Firestore database. Then, we’ll build a Next.js application and we’ll create the pages and the API routes to create, edit, fetch and delete entries from our new database.

Choosing between Firebase’s Realtime DB and Cloud Firestore

Firebase now offers two database solutions: Cloud Firestore and Realtime Database. On paper, they seem very similar. Both are NoSQL databases that come with a client SDK, real-time updates, and a free tier. You may be wondering if there is actually a big difference between the two.

Choosing one over the other will largely depend on your needs. If you need a lightweight database, with simple querying and know that your data will be accessed often, then go with Realtime Database.

On the other hand, if you need more advanced querying functionality along with more structure for your data, then Cloud Firestore is likely a better option.

Setting up Cloud Firestore

We’ll start by setting up the Firebase project. Go to the Firebase site and click Create a project. Firebase offers a variety of services, but for this article, we’ll focus on Cloud Firestore.

Under the Build tab on the sidebar, click on Firestore Database and then click on Create database. Select Start in the production mode option when prompted and choose the location closest to you. This is important to reduce data latency.

Now, the database is set up. The next step is to create credentials, which we’ll use to initialize and access the database in our Next.js app.

Click the gear icon next to Project Overview at the top of the sidebar and navigate to Project settings. Next, go to the Service accounts tab and click on Generate new private key in order to generate the JSON file that we’ll use with the example code shown on the same page.

We made a custom demo for .
No really. Click here to check it out.

Setting up the Next.js app

Now, we’ll create the Next.js app and configure the database connections. The easiest way to get started with Next.js is to use the following command to create a Next.js project in the specified folder:

npx create-next-app demo-app

Go to the demo-app directory on your console and run npm run dev to start the development server. You should see a welcome page when you visit localhost:3000 in your browser.

Connecting to the database

Next, let’s create a utility function to communicate with the database. First, we’ll install firebase-admin as a dependency. We’ll also use the axios and dashify libraries, so let’s install those as well.

npm i firebase-admin axios dashify

Now, create utils/db/index.js relative to the project’s root directory.

Next, we’ll copy and paste the JSON file that we downloaded in the previous step into the utils/db folder and rename it serviceAccountKey.json.

Tip: Make sure to also add this file path to your .gitignore. Your serviceAccountKey.json file contains secret keys that shouldn’t be committed to Github by accident.

Copy and paste the following code block into utils/db/index.js:

import admin from 'firebase-admin';
import serviceAccount from './serviceAccountKey.json';

if (!admin.apps.length) {
  try {
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount)
    });
  } catch (error) {
    console.log('Firebase admin initialization error', error.stack);
  }
}
export default admin.firestore();

Creating the endpoints

Next.js has a very handy API routes solution, which helps create API endpoints under the /pages/api folder. Let’s create /pages/api/entry/[id].js and /pages/api/entry/index.js. The first endpoint captures the id sent in the request and makes it available in the function. We’ll touch on that later. We’re going to have four different request methods: POST, GET, PUT, and DELETE.

  • POST is for adding a new database entry
  • PUT is for updating an existing database entry
  • GET is for fetching the existing database entry
  • DELETE is for deleting the existing database entry

Let’s start with the POST method, where we’ll add a new entry to the entries collection. Take the following request sent to that endpoint as an example:

axios.post('/api/entry', { title: Foo Bar, slug: foo-bar, body: lorem ipsum });

This is the content of the /pages/api/entry/index.js:

import db from '../../../utils/db';

export default async (req, res) => {
  try {
    const { slug } = req.body;
    const entries = await db.collection('entries').get();
    const entriesData = entries.docs.map(entry => entry.data());

    if (entriesData.some(entry => entry.slug === slug)) {
      res.status(400).end();
    } else {
      const { id } = await db.collection('entries').add({
        ...req.body,
        created: new Date().toISOString(),
      });
      res.status(200).json({ id });
    }
  } catch (e) {
    res.status(400).end();
  }
}

In this code, we imported the db object from /utils/db in order to use it to establish communication with the database. Firestore has collections, and each collection has multiple documents.

The documents contain fields or collections again, just like a JSON structure. In the above function, which we’ll export from /api/entry/index.js, we’ll use the slug key’s value to check if there is already another entry with the same slug value. If so, we’ll end the request with a 400 status code. You can possibly specify another status code, or pass a message to show on the UI. For the sake of simplicity, we’ll just end the request without sending any data.

Next, we’ll specify the name of the collection and the data to be added. If the collection does not already exist, it will be automatically created with the specified name and a document will be added with a random id. This document contains the data we passed.

After verifying that the slug is unique, we’ll make another request to db to add the request body, which is an object containing a title, slug, and body:

// Example data
{
  title: 'Foo bar',
  slug: 'foo-bar', // This is auto generated on the client side
  body: 'Lorem ipsum',
}

We’ll spread that object into another object, and also add the created key with the current timestamp as a value. Now it is time to create the PUT, GET, and DELETE endpoints. These will be handled in /api/entry/[id].js.

Here are examples of these requests:

await axios.get(`/api/entry/${id}`);
await axios.delete(`/api/entry/${id}`);
await axios.put(`/api/entry/${id}`, {
  slug: 'foo-bar',
  title: 'Foo Bar',
  body: 'Lorem ipsum'
});

You’ll notice how the id variable is used in the endpoint path. Since we use square brackets with the id filename, it becomes available in req.query.

We’ll check the req object’s method key to determine the request type. If it is a PUT method, we’ll find the document in the entries collection by id and then update it with an object.

We’ll also add another key named updated with the current timestamp. This way, we can show when an entry is updated. This is, of course, just a demonstration of an optional feature. If it is a GET method, we’ll find the document by id and then return doc.data() as a response. If it is a DELETE method, we’ll find the document by id and then delete the document.

This is the content of the /pages/api/entry/[id].js:

import db from '../../../utils/db';

export default async (req, res) => {
  const { id } = req.query;

  try {
    if (req.method === 'PUT') {
      await db.collection('entries').doc(id).update({
        ...req.body,
        updated: new Date().toISOString(),
      });
    } else if (req.method === 'GET') {
      const doc = await db.collection('entries').doc(id).get();
      if (!doc.exists) {
        res.status(404).end();
      } else {
        res.status(200).json(doc.data());
      }
    } else if (req.method === 'DELETE') {
      await db.collection('entries').doc(id).delete();
    }
    res.status(200).end();
  } catch (e) {
    res.status(400).end();
  }
}

So far, all of these endpoints were used to control a single entry. However, we’ll also need an endpoint in order to fetch entries and list entries. For this purpose, we’ll create one more endpoint: /api/entries.js.

First, we’ll fetch all documents in the entries collection and simultaneously order them with the created key, which we added while implementing the endpoint for the POST method. Then, we’ll map through the returned value and return a new array of objects where we’ll spread the entry data and also add the document id.

We’ll need this id in order to use it with the GET, DELETE, and PUT methods. This is the content of the /pages/api/entries.js:

import db from '../../utils/db';

export default async (req, res) => {
  try {
    const entries = await db.collection('entries').orderBy('created').get();
    const entriesData = entries.docs.map(entry => ({
      id: entry.id,
      ...entry.data()
    }));
    res.status(200).json({ entriesData });
  } catch (e) {
    res.status(400).end();
  }
}

We’ll use this endpoint as follows:

await axios.get('/api/entries');

Now that we’re done creating endpoints, we’ll work on implementing the pages.

Creating the routes in Next.js

In the simplest form, we need an /admin and a /posts route. These are, of course, arbitrary names. In the /admin routes, we need to be able to post an entry and list all entries so that we can select and edit an entry. In the /posts routes, we need to be able to list all entries and also navigate into a specific entry.

Here’s how the final structure of the /pages folder will look:

|-- api
|-- admin
    |-- post.js
    |-- edit
        |-- index.js
        |-- [id].js
|-- posts
    |-- index.js
    |-- [slug].js

Again, we’ll use square brackets with the id and slug under the /admin/edit and /posts routes, respectively. These dynamic routes let us use the same page template to create different routes with different content.

/admin routes

Let’s start with /admin routes. Note that we didn’t put the /admin route behind an authentication to make it safe. In an actual application, this is obviously required.

/admin/post.js

Here, we have one state variable, which is an object holding title and body properties. After setting the title and body objects, we can send these values as an entry.

The onSubmit function will make a POST call to /api/entry, along with an object that contains the title, body, and slug. The slug is generated from the title variable through the dashify package.

import { useState } from 'react';
import dashify from 'dashify';
import axios from 'axios';

const Post = () => {
  const [content, setContent] = useState({
    title: undefined,
    body: undefined,
  })
  const onChange = (e) => {
    const { value, name } = e.target;
    setContent(prevState => ({ ...prevState, [name]: value }));
  }
  const onSubmit = async () => {
    const { title, body } = content;
    await axios.post('/api/entry', { title, slug: dashify(title), body });
  }
  return (
    <div>
      <label htmlFor="title">Title</label>
      <input
        type="text"
        name="title"
        value={content.title}
        onChange={onChange}
      />
      <label htmlFor="body">Body</label>
      <textarea
        name="body"
        value={content.body}
        onChange={onChange}
      />
      <button onClick={onSubmit}>POST</button>
    </div>
  );
};

export default Post;

/admin/edit/index.js

On the edit page, we’ll list all entries by fetching them from the /api/entries endpoint.
Then, we’ll use the id key to link to that specific entry, which will be matched by admin/edit/[id].js.

import { useEffect, useState } from 'react';
import Link from 'next/link'
import axios from 'axios';

const List = () => {
  const [entries, setEntries] = useState([]);
  useEffect(async () => {
    const res = await axios.get('/api/entries');
    setEntries(res.data.entriesData);
  }, []);

  return (
    <div>
      <h1>Entries</h1>
      {entries.map(entry => (
        <div key={entry.id}>
          <Link href={`/admin/edit/${entry.id}`}>
            <a>{entry.title}</a>
          </Link>
          <br/>
        </div>
      ))}
    </div>
  );
};

export default List;

/admin/edit/[id].js

Here, we’ll use the /api/entry/[id].js endpoint with the GET method in order to fetch the data of a specific entry by id.

Then, we’ll populate the page’s title and body fields with the data fetched from the database.
We’ll use the onSubmit and onDelete methods to send requests to /api/entry/[id].js and the PUT and DELETE methods to update and delete the entry, respectively.

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router'
import dashify from 'dashify';
import axios from 'axios';

const EditEntry = () => {
  const router = useRouter()
  const [content, setContent] = useState({
    title: undefined,
    body: undefined,
  })

  useEffect(async () => {
    const { id } = router.query;
    if (id) {
      const res = await axios.get(`/api/entry/${id}`);
      const { title, body } = res.data;
      setContent({
        title,
        body
      })
    }
  }, [router])

  const onChange = (e) => {
    const { value, name } = e.target;
    setContent(prevState => ({ ...prevState, [name]: value }));
  }

  const onSubmit = async (e) => {
    const { id } = router.query
    const { title, body } = content;
    console.log(id, title, body);
    await axios.put(`/api/entry/${id}`, {
      slug: dashify(title),
      title,
      body,
    });
  }

  const onDelete = async () => {
    const { id } = router.query;
    await axios.delete(`/api/entry/${id}`);
    router.back();
  }

  return (
    <div>
      <label htmlFor="title">Title</label>
      <input
        type="text"
        name="title"
        value={content.title}
        onChange={onChange}
      />
      <label htmlFor="body">Body</label>
      <textarea
        name="body"
        value={content.body}
        onChange={onChange}
      />
      <button
        type="button"
        onClick={onSubmit}
      >
        Submit
      </button>
      <button
        type="button"
        onClick={onDelete}
      >
        Delete
      </button>
    </div>
  );
};

export default EditEntry;

None of these /admin routes are server-side rendered as they are not meant to be public and therefore can be rendered on the client.

/posts routes

The /posts routes are public, so they can be server-side rendered for performance and better SEO results.

/posts/index.js

This page is where we’ll list all entries. We’ll export getStaticProps, which is a Next.js specific function. By exporting it, we tell Next.js to server-side render that page at build time. Everything in the getStaticProps function is server-side rendered and not exposed to the client. Likewise, all the imports and definitions outside this function are not included in the code sent to the client if they are used in getStaticProps.

When generating this page on the server, we’ll first need to fetch the entries in order to be able to list them. Therefore, we’ll import our db object and then make a request to the database.

After getting the entries, we’ll need to make them available in the component. The object returned from getStaticProps becomes available as props in the component. It should always return an object with the props key, and this is where we’ll pass the fetched data:

return {
  props: {
    entriesData
  }
}

This method works well for truly static pages. However, in our case, when we post a new entry, we’ll need to trigger a build so that the page is regenerated with data from new entries. Alternatively, we could server-side render the page on request-time, but this option is costly and slower. Next.js has a solution for this: Incremental Static Regeneration.

By adding the revalidate key with a timeout value in seconds, we’ll tell Next.js to regenerate the page when it is requested. So, the first person visiting the page will be served the existing page, which is built when the app is deployed. That first request triggers a new build, so the next person visiting the page will be served the newly generated page.

The duration determines how long the server should wait for triggering another regeneration upon a request. For example, if the duration value is set to 600, which corresponds to 10 minutes, then the very first request to that page will trigger a regeneration. The upcoming requests within 10 minutes will not trigger another regeneration, and they will be served the lastly generated page after the first request. Once the 10-minute duration expires, the first request coming triggers another regeneration, and so on.

Here, we’ll also use the <Link/> component, which is provided by Next.js for client-side route transition. We’ll pass /posts/${entry.slug} to the href prop so that /posts/[slug].js can match it.

import Link from 'next/link'
import db from '../../utils/db';

const Posts = (props) => {
  const { entriesData } = props;

  return (
    <div>
      <h1>Posts</h1>
      {entriesData.map(entry => (
        <div key={entry.id}>
          <Link href={`/posts/${entry.slug}`}>
            <a>{entry.title}</a>
          </Link>
          <br />
        </div>
      ))}
    </div>
  );
};

export const getStaticProps = async () => {
  const entries = await db.collection('entries').orderBy('created', 'desc').get();
  const entriesData = entries.docs.map(entry => ({
    id: entry.id,
    ...entry.data()
  }));
  return {
    props: { entriesData },
    revalidate: 10
  }
}

export default Posts;

/posts/[slug].js

In getStaticProps, we’ll capture the slug from *context*.params and use this to filter the entries collection to find the document matching that specific slug value. Then, we’ll return the data of that entry as a prop. If the slug entered doesn’t exist, then we’ll return an empty props object, since we must return an object with the props key either way.

What is new here is the use of getStaticPaths. When a page has dynamic routes and has getStaticProps, then it needs to use the getStaticPaths as well, where we define a list of paths that will be generated at build time.

In getStaticPaths, we fetch all the entries. By using the slug value of each entry, we create an array of objects with the following structure:

params: {
  slug: ...
}

Since we used slug in the file name with square brackets, we’ll need to use the key name of slug in the params object as well.

The array we’ll generate is passed to the paths key of the object returned by getStaticPaths.
In addition to the paths key, we’ll also have the fallback:true key-value pair.

This fallback key must be set. If it is set to false, then Next.js returns a 404 page for all the paths that are not generated during build time. If we add a new entry, then we must run the build again to generate these paths. However, setting fallback to true triggers a static generation in the background.

In order to detect the fallback setting and show a loading page accordingly, a router object can be used. router.isFallback becomes true during generation and then becomes false once the path is generated.

Below, see how router.isFallback is used to return a loading… text. If the path visited does not match with a slug in the database, then getStaticProps returns an empty props object and that results in not found text being displayed.

import { useRouter } from 'next/router'
import db from '../../utils/db';

const Post = (props) => {
  const { entry } = props;
  const router = useRouter()
  if (router.isFallback) {
    return (
      <div>loading</div>
    )
  } else {
    if (entry) {
      return (
        <div>
          <h1>{entry.title}</h1>
          <h4>{entry.created}</h4>
          <p>{entry.body}</p>
        </div>
      );
    } else {
      return (
        <div>not found</div>
      )
    }
  }
};

export const getStaticPaths = async () => {
  const entries = await db.collection("entries").get()
  const paths = entries.docs.map(entry => ({
    params: {
      slug: entry.data().slug
    }
  }));
  return {
    paths,
    fallback: true
  }
}

export const getStaticProps = async (context) => {
  const { slug } = context.params;
  const res = await db.collection("entries").where("slug", "==", slug).get()
  const entry = res.docs.map(entry => entry.data());
  if (entry.length) {
    return {
      props: {
        entry: entry[0]
      }
    }
  } else {
    return {
      props: {}
    }
  }
}

export default Post;

Conclusion

In this article, we demonstrated how to build a full-stack app with Next.js and Cloud Firestore. We covered how to set up Firebase’s Cloud Firestore and how to communicate with this database through a Next.js application.

We discussed how to create API endpoints and use those endpoints inside various components. We also demonstrated dynamic routing and server-side rendering for static page generation and explained how to dynamically regenerate existing or non-existing static pages.

For more information about Firebase’s Cloud Firestore, see the official documentation.

LogRocket: Full visibility into production Next.js apps

Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your Next.js apps — .

Doğacan Bilgili A software developer who is also into 3D-modeling and animation.

9 Replies to “Building a full-stack app with Next.js and Firebase’s Cloud…”

  1. pages/api/entries.js should be something like this:
    import db from “../../utils/db”;

    export default async (req, res) => {
    try {
    const ref = db.collection(“entries”);
    const collections = await ref
    .orderBy(‘created’)
    .get();
    const entriesData = [];
    collections.forEach(doc => {
    entriesData.push({ …doc.data(), id: doc.id });
    })
    res.status(200).json({ entriesData });
    } catch (_) {
    res.status(400).end();
    }
    };

  2. I appreciate you taking the time to post this. It is never easy to put something out there. I just wanted to make you aware of some issues. I tried to follow this guide and ran into a lot of issues.
    – Right out of the gate the site wouldn’t load because having no argument in the catch caused a syntax error. I would recommend to anyone doing this at home to name the argument and console.log it to help with troubleshooting.
    – With that tweak, you can create a record by navigating to the admin/post page. However, since you are using `collection(‘entries’).add` in `api/entry/index.js` each new record is being assigned a document name of a random string. This breaks the selection code in `api/entry/[id].js` since it is selecting the document by the slug value. Changing that code in `api/entry/index.js` to `collection(‘entries’).doc(slug).set`solves that issue.
    – Your api/entries.js file isn’t selecting all of the existing entries. In fact, it matches the [id].js so it is requiring an id, which the edit/index.js and posts.file is not providing. Seems like a copypasta issue.
    A couple of less important housekeeping items:
    – `db.collection(‘entries’)` is repeated a lot in your api files, I would recommend setting a collection const at the top of the file and reusing that below.
    – out of the box, for some reason, next cannot seem to find the favicon and also has a console warning stating that the static directory is being depreciated and to use the public directory. Moving everything from the static directory to public solves both issues.
    – When you get them working, the `admin/edit/index.js` and `posts/index.js` components are iterating over an array of posts/entries but have no key defined. The fragment shorthand, “, should not be used when iterating, since it doesn’t support keys. Since slug is unique in your app, it will work for the key. You just need import React and use the long “ version.

    I hope this all helps and thank you again for taking a first stab at this. Even though it didn’t work out of the box, it has been a fun challenge getting it to work.

  3. Hey Keith,
    Thanks for the feedback. There were a couple of broken code blocks due to c/p errors and these were causing the issues you’ve mentioned. Now the whole article is revised and a new version will be published soon.

  4. Nice little snippets! But it would be useful to have an overview what you are going to build/building, I assume it’s a blog admin interface. Then maybe a bit about the NextJS implementation. Do you use it client side or both client/server generated code?

  5. Hey my config doesn’t have a databaseURL entry. After a lot of googling I have no idea if that matters or not. Seems like the databaseURL matters more for realtime database vs firestore but I can’t confirm if that is true. I’m planning on using firestore though 😬

  6. You should not use an async callback inside useEffect because it can cause race conditions. Instead, create an aync function inside of useEffect.

Leave a Reply