Doğacan Bilgili A software developer who is also into 3D Modeling/Animation.

Building a full-stack application with Next.js and Firestore DB

11 min read 3209

Next and Firebase logos with an image of a ferris wheel.

Setting up the Cloud Firestore

We’ll start by setting up the Firebase project. Go to this link and create a new project. Firebase offers a variety of services, but for this article, we are going to focus on the Cloud Firestore service: a NoSQL database solution.

Under the Develop tab on the sidebar, find Cloud Firestore and create a new database. Select Start in the production mode option when prompted. Now, the database is set up. The next step is to create credentials, which we are going to use in order to initialize and access the database in the 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 the Generate new private key button to generate the JSON file, which you are going to use with the example code shown on the same page.

Setting up the Next.js app

Now, we can 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, which creates a Next.js project in the specified folder. In this case, it’s demo-app:

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

The next step is to create a utility function to communicate with the database. First, install firebase-admin as a dependency. Then, create utils/db/index.js relative to project’s root directory. Now you’ll copy and paste the JSON file you downloaded in the previous step into the utils/db folder and rename it serviceAccountKey.json.

Copy and paste the following code block into utils/db/index.js and replace databaseURL key’s value with your own database URL, which you can find under the Service accounts tab. We navigated to this tab in order to generate the private key JSON file. The code snippet in that tab contains the URL of your database.

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

if (!admin.apps.length) {
  try {
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
      databaseURL: "YOUR_DB_URL"
    });
  } 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 you create your API endpoints under the /pages/api folder.

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

Let’s create /pages/api/entry/[id].js and /pages/api/entry/index.js.

The first one captures the id sent in the request and makes it available in the function. We will touch on that later.

We are going to have four different request methods. These are 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 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();
  }
}

We imported the db object from /utils/db in order to use it to establish the 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 export from /api/entry/index.js, we use the slug key’s value to check if there is already another entry with the same slug value. If so, we end the request with a status code of 400. You can possibly specify another status code, or pass a message to show on the UI. For the sake of simplicity, we will just end the request without sending any data.

We specify the name of the collection and the data to be added. If the collection is not already created, it automatically gets created with the specified name. A document also gets added with a random id. This document then contains the data we passed.

Once we make sure that the slug is unique, we then make another request to db to add the request body, which is an object containing title, slug, and body.

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

We spread that object into another object, and also add the created key with the current time stamp as value.

Now it is time to create PUT, GET, and DELETE endpoints. These will be handled in /api/entry/[id].js.

Examples of these requests are:

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 file name of id, it becomes available in req.query.

Below, we check the method key of the req object to figure out what kind of request it is.

If it is a PUT method, we 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 time stamp. 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 again find the document by id and then return doc.data() as a response.

If it is a DELETE method, we once again find the document by id and then just delete it.

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 to control a single entry. However, we also need an endpoint to be able to fetch all the entries to list them.

For this purpose, we are going to create one more endpoint named /api/entries.js.

Below we’ll fetch all the documents in the entries collection and at the same time we order them with the created key, which we added when we were implementing the endpoint for the POST method.

Then, we’ll map through the returned value and return a new array of objects where we spread the entry data and also add the document id. We need this id in order to use with 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 are going to use this endpoint as follows:

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

Now we are done with our endpoints. The next thing to do is to implement the pages.

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 the entries so that we can select and edit one. In the /posts routes, we need to be able to list all the entries and also navigate into a specific entry.

So, the final structure of the /pages folder will look like this:

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

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

/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 a real-life application, this is obviously required.

/admin/post.js

Here we have one state variable, which is an object holding the title and body properties.

After setting the title and body objects, we can send these values as an entry.

The onSubmit function makes a POST call to /api/entry, along with and object that contains the title, body, and slug. Slug is generated out of the title variable through 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">Title</label>
      <textarea
        name="body"
        value={content.body}
        onChange={onChange}
      />
      <button onClick={onSubmit}>POST</button>
    </div>
  );
};

export default Post;

/admin/edit/index.js

In the edit page, we listed all the entries by fetching them from the /api/entries endpoint.

Then, we used 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 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 populate the title and body fields in the page with the data fetched from the database.

We have onSubmit and onDelete methods, and both of these send a request to /api/entry/[id].js with PUT and DELETE methods in order 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 due to the fact that they are not meant to be public and therefore can be rendered on the client.

/Posts routes

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

/posts/index.js

This page is where we list all the entries. We export getStaticProps, which is a Next.js specific function. By exporting it, we tell Next.js to server-side render that page on 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 is not included in the code sent to the client if they are used in getStaticProps.

When generating this page on the server, we need to have the entries to be able to list them, so we need to fetch them. Therefore, we import our db object and then make a request to the database. When we get our entries, we somehow 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 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 either need to trigger a build so that this page is regenerated again with the new entries data, or we need to server-side render this page on request-time, which is costly and slower. Next.js has a solution to this, which is called Incremental Static Regeneration.

By adding the revalidate key with a timeout value in seconds, we tell Next.js to regenerate this page when someone requests it. So, the first person visiting the page will be served the existing page, which is built when the app is deployed. But that first request triggers a new build and 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.

So, for example, if this 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, first request coming triggers another regeneration, and so on.

Here, we also use the <Link/> component, which is provided by Next.js for client-side route transition. We 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 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 return the data of that entry as a prop just like we did in the previous component. If the slug entered doesn’t exist, then we do not return anything but 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 need to use the key name of slug in the params object as well.

The array we generated is passed to the paths key of the object returned by getStaticPaths.

In addition to the paths key, we 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 the build-time. If you add a new entry, then you need to run the build again to generate these paths. However, setting fallback to true triggers a static generation in the background.

In order to detect this 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 covered how to create a database by using the Cloud Firestore service of Firebase and how to communicate with this database through a Next.js application. On Next.js, we learned how to create API endpoints and use these endpoints inside various components. On top of that, we demonstrated dynamic routing and server-side rendering for static page generation and how to dynamically regenerate existing or non-existing static pages.

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 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/Animation.

6 Replies to “Building a full-stack application with Next.js and Firestore DB”

  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.

Leave a Reply