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.
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.
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.
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.
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();
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 entryPUT
is for updating an existing database entryGET
is for fetching the existing database entryDELETE
is for deleting the existing database entryLet’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.
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
routesLet’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
routesThe /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;
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.
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.js 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 — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
14 Replies to "Building a full-stack app with Next.js and Firebase’s Cloud Firestore"
Hi, the contents of `/api/entries.js` & `/api/entry/[id].js` seem to be identical.
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();
}
};
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.
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.
Excited to see what you come out with. Any chance you will be doing a post about integrating firebase auth into nextjs?
Supposed that firebase authentication was used how can I use getStaticProps with it
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?
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 😬
You should not use an async callback inside useEffect because it can cause race conditions. Instead, create an aync function inside of useEffect.
Did you even ran this code ?
I think its a terrible idea to read each documents to see if the slug is unique, if your database grow up and have like 2000 documents in it, you will need to read 2000 documents just for writting ONE SINGLE DOCUMENT !
It very dangerous to publish a tutorial were you, because some people will simple copy/paste the code, go in prod and get 2000$ of firestore fees.
To check if a slug is unique, you can simply name your document with the slug. So you can just list all the document (in one single read) and with that you can read the documents id and check if the document id match with your slug.
Here is my code to do that:
pages/api/entry/index.js
import dn from “../../../utils/db”;
export default async (req,res) => {
console.log(“executed”);
try{
const {slug} = req.body
const posts = await db.collection(‘posts’).get();// change the name of the collection
var exist = false;
posts.forEach((post)=> {if(post.id == slug){exist = true}})// check if the slug exist
if ( exist ){
//console.log(“slug exist”);
res.status(200);
}else{
const {id} = await db.collection(“posts”).doc(slug).set({// doc(slug) will name (set the id of the doc) with the slug
…req.body,
created: new Date().toISOString(),
})
res.status(200).json({id});
}
}catch(e){
console.log(e);
res.status(400).end();
}
}
Is there a place(github) where this project is hosted?
as good as this project sounds, not including a live demo or a snippet of what we’re looking to build is wrong.
Just great, thx
It looks to me like you are using service credentials to completely bypass user authentication, but Next.js API routes are publicly accessible. In a browser they are protected by CORS maybe, but the routes can still be called from a non-browser environment. So in other words, I think this example code has a huuuge vunerability.