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.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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.jsHere, 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.jsOn 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].jsHere, 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.jsThis 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].jsIn 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 captures console logs, errors, network requests, and pixel-perfect DOM recordings from user sessions and lets you replay them as users saw it, eliminating guesswork around why bugs happen — compatible with all frameworks.
LogRocket's Galileo AI watches sessions for you, instantly identifying and explaining user struggles with automated monitoring of your entire product experience.
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.

A hands-on test of Claude Code Review across real PRs, breaking down what it flagged, what slipped through, and how the pipeline actually performs in practice.

CSS art once made frontend feel playful and accessible. Here’s why it faded as the web became more practical and prestige-driven.

Learn how inline props break React.memo, trigger unnecessary re-renders, and hurt React performance — plus how to fix them.

This article showcases a curated list of open source mobile applications for Flutter that will make your development learning journey faster.
Would you be interested in joining LogRocket's developer community?
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 now