.png&w=1920&q=75)
In the last article, we laid the foundation: a VPS, nginx as a reverse proxy, a NestJS backend with Firebase-based authentication, and a MySQL database. The backend can accept requests, check tokens, and knows how to store ToDos. What’s still missing is the layer that real people interact with — a website where you can log in and view, create, edit, and delete your ToDos.
This article is exactly for that. We’ll build a minimalist NextJS website that does just that: login/logout via Firebase, fetch ToDos from the backend (with the required Firebase ID token), create new ToDos, mark them as completed, and delete them. Minimalist here means: no user profile, no fancy animations — instead, clear, easy-to-follow code that even beginners can replicate right away.
Why NextJS? Because it combines two strengths that are useful for many projects: modern React development (component-based) and the option for server-side rendering (if we want that later). In our case, we need one important capability of NextJS: the app runs in the browser, and there we fetch the Firebase ID token of the logged-in user, which we send to our NestJS backend via Authorization: Bearer <token>. Since the protected endpoints expect a valid token, Firebase integration is not optional — it must happen right at the start for the API calls to work. Of course, you can also remove the FirebaseGuard from your backend endpoints. Then you can skip the Firebase integration step and focus only on the ToDos.
By the end of this article, you will be able to:
set up a NextJS app and understand the project structure,
integrate the Firebase Client SDK and use email/password authentication,
build a global AuthProvider and a useAuth() hook to make the logged-in user and their token available throughout the app,
configure Axios so that the Firebase token is sent in the header with every API request,
load the ToDo list from the backend, create new ToDos, mark ToDos as completed, and delete them — each via the real, protected endpoints of your backend.
Before you get started, make sure you have the following:
A running VPS with your NestJS backend, accessible via a domain or IP (e.g., https://your-domain.com).
A Firebase project with email/password authentication enabled and the client config (apiKey, authDomain, projectId, etc.).
Node.js and npm installed on your local machine.
A code editor (e.g., VS Code) and a terminal.
Optional: Postman or other tools to test API calls.
If you’re missing any of these points, you’ll find hints in the respective sections on how to set them up — but I assume the backend & Firebase project from articles 2 and 3 already exist.
Before we can bring our ToDo website to life, we first need to set up a Next.js project. Don’t worry: this is pretty quick, and if you follow the steps, you’ll see your first working page at the end.
Make sure you have Node.js installed on your computer. You can check this by entering the following command in your terminal or command prompt:
node -vIf a version number appears (e.g., v20.x.x), you’re good to go. If not, download Node.js from the official website:
👉 https://nodejs.org
Additionally, it’s recommended to use npm or yarn as a package manager. npm comes with Node.js, so you don’t need to install anything extra.
Next.js comes with its own starter command that automatically sets up the project structure. In your terminal, navigate to the folder where you want your project and enter:
npx create-next-app@latest todo-websiteHere’s what happens:
npx runs a package directly without you having to install it first.
create-next-app is the Next.js tool to set up a new project.
todo-website is the name of the project. You can choose any name you like.
You’ll then be asked a few questions, e.g., whether you want to use TypeScript, enable ESLint, etc. For our project, I recommend:
TypeScript: yes (makes our project more robust and is standard in the NestJS world).
ESLint: yes (helps you write clean code).
App Router: yes (this is the new standard in Next.js).
You can keep the default settings for everything else.
Switch to the newly created folder:
cd todo-websiteAnd start the development server:
npm run devIf everything works, you can now open http://localhost:3000 in your browser and see the default Next.js start page. 🎉
Before we continue, let’s briefly look at what was created. The most important parts:
app/: Here we create our pages and layouts. For example, there will be a page where the ToDos are displayed.
public/: Here you can store static files (e.g., images).
package.json: Contains all dependencies and scripts needed for your project.
next.config.js: Here we can make special configurations for Next.js, but we don’t need this for now.
To get a feel for how easy it is to create pages in Next.js, let’s try something out.
Create a new file in the app/ folder, e.g., app/todos/page.tsx. The name page.tsx is important so Next.js knows this is a page.
Write the following code:
export default function TodosPage() {
return (
<div>
<h1>My ToDos</h1>
<p>All tasks will appear here soon.</p>
</div>
);
}If you now open http://localhost:3000/todos in your browser, your new page will appear. 🎉
That’s it for the basics: you’ve created a Next.js project, started it, and built your first page. In the next chapter, we’ll take care of Firebase integration so our website knows which user is logged in.
For our ToDo website to know which user is logged in, we need user authentication. We use Firebase Authentication because it’s extremely easy to set up and supports many login methods (email/password, Google login, etc.).
We’ll proceed step by step:
In the Firebase project dashboard, click "Add app" and select the web symbol (</>).
Give your app a name, e.g., todo-website.
Do not enable Firebase Hosting – we don’t need it, as we’ll host the website on our own server.
Click "Register".
You’ll then get a code snippet that looks something like this:
const firebaseConfig = {
apiKey: "AIza....",
authDomain: "todo-app.firebaseapp.com",
projectId: "todo-app",
storageBucket: "todo-app.appspot.com",
messagingSenderId: "123456789",
appId: "1:123456789:web:abcdefg"
};👉 You’ll need this data in your project shortly.
Go to your project and install the Firebase packages:
npm install firebaseNow we have everything we need to use Firebase in Next.js.
Create a new folder lib/ in your project. There, create a file firebase.ts:
// lib/firebase.ts
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_AUTH_DOMAIN",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_STORAGE_BUCKET",
messagingSenderId: "YOUR_SENDER_ID",
appId: "YOUR_APP_ID",
};
// Initialize Firebase App
const app = initializeApp(firebaseConfig);
// Export Firebase Auth
export const auth = getAuth(app);⚠️ Important: Replace the data in firebaseConfig with your own from the Firebase console.
So we always know in our app whether a user is logged in or not, we create a provider.
Create context/AuthContext.tsx in your project folder:
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { onAuthStateChanged, User } from "firebase/auth";
import { auth } from "@/lib/firebase";
// 1. Create context
const AuthContext = createContext<{ user: User | null }>({ user: null });
// 2. Provider component
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// Firebase listener: called when login status changes
const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
setUser(firebaseUser);
});
return () => unsubscribe();
}, []);
return (
<AuthContext.Provider value={{ user }}>
{children}
</AuthContext.Provider>
);
}
// 3. Custom hook for easy access
export function useAuth() {
return useContext(AuthContext);
}To make the provider available everywhere, include it in app/layout.tsx:
import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}To allow a user to log in, we need buttons for login and logout.
In this example, we use Google login because it’s the easiest. You can expand this later, of course.
Create a new component:
/components/AuthButtons.tsx
"use client";
import { auth } from "@/lib/firebase";
import { GoogleAuthProvider, signInWithPopup, signOut } from "firebase/auth";
import { useAuth } from "@/contexts/AuthContext";
export default function AuthButtons() {
const { user } = useAuth();
const handleLogin = async () => {
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
};
const handleLogout = async () => {
await signOut(auth);
};
if (user) {
return (
<div>
<p>Logged in as: {user.email}</p>
<button onClick={handleLogout}>Logout</button>
</div>
);
}
return (
<div>
<p>You are not logged in</p>
<button onClick={handleLogin}>Sign in with Google</button>
</div>
);
}Now you have a small component that shows either a login button or the user info with logout button.
If it doesn’t work right away, follow this Firebase documentation: https://firebase.google.com/codelabs/firebase-nextjs?hl=de#5. Maybe your domain isn’t authorized in Firebase yet?
To check if everything works, let’s modify app/page.tsx as follows:
"use client";
import { useAuth } from "@/context/AuthContext";
export default function HomePage() {
return (
<div>
<h1>Welcome to my ToDo App</h1>
<AuthButtons/>
</div>
);
}👉 If you now log in to Firebase (e.g., via the Firebase Console or later via a login form), you’ll see the user directly in your app.
If you now run npm run dev and open your app in the browser, you should be able to log in via Google. Afterwards, your user will be displayed at the top. If you log out, the display disappears again.
✅ We’ve now laid the foundation: Our app now knows the current user.
In the next step, we’ll use this information to call our API endpoints and load the ToDos.
Now it gets exciting, as we build the first real page of our website: an overview of all ToDos for the logged-in user. That’s exactly what we’ve set up so far — we’ve added authentication and a backend with a ToDo API. Now it’s about fetching the data from this API and displaying it in the frontend.
To keep our app organized, we’ll create a dedicated page. In Next.js, this is done by simply creating a new file in the pages folder. So let’s create the file todos.tsx. This page will later contain the overview where all tasks of the logged-in user are displayed.
The basic structure of this page looks like this:
// pages/todos.tsx
import { useEffect, useState } from "react";
import { useAuth } from "../hooks/AuthProvider";
export default function TodosPage() {
const { user } = useAuth();
const [todos, setTodos] = useState([]);
useEffect(() => {
const fetchTodos = async () => {
if (!user) return;
const token = await user.getIdToken();
const response = await fetch("http://your-domain.com/todos", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
setTodos(data);
};
fetchTodos();
}, [user]);
return (
<div>
<h1>Your ToDos</h1>
<ul>
{todos.map((todo: any) => (
<li key={todo.id}>
{todo.title} {todo.completed ? "✅" : "❌"}
</li>
))}
</ul>
</div>
);
}Let’s take a closer look at what’s happening here. At the top, we import useEffect and useState. These two React hooks are like little tools that help us store data and automatically execute certain things when the page loads. With useState, we create a small storage where our ToDos land as soon as we get them from the server. With useEffect, we say: “As soon as something important changes — in this case, when a user is logged in — run this function.”
In this function fetchTodos is where the magic happens. First, we check: is a user logged in? If not, there’s no point in fetching ToDos. If a user is present, we use getIdToken() to get the so-called JWT token. This token is like a digital ID that confirms: “Yes, this user is really authenticated with Firebase.” We then attach this token to our request to the backend — in the header under Authorization: Bearer <token>.
Our NestJS backend automatically checks this token using the Firebase Guard. If the token is valid, the ToDos for exactly this user are loaded from the database and sent back. In the frontend, we receive this data, convert it to JSON, and store it with setTodos in our state.
The last part is the display. With a simple <ul> element (an unordered list), we display each ToDo. Inside the loop (todos.map), we go through all tasks and list them one after another. We display the title and show a checkmark if the task is completed, or a cross if it’s still open.
If you now log in and open the page http://localhost:3000/todos, you should see the tasks that belong to your user. If you haven’t created any yet, the list will be empty — but that’s perfectly fine, because in the next step we’ll take care of how you can create new ToDos.
Now you have the first visible proof that your system works: a user logs in, fetches their data from the backend with their token, and sees their personal tasks in the browser. 🎉
So far, we can view a user’s ToDos stored in the database. But our list is probably still empty, since we haven’t created any tasks ourselves yet. In this chapter, we’ll add the ability to create a new ToDo directly on our website.
The idea is simple: we build a small form where the user can enter the title of a task. As soon as they submit the form, we send the data to our backend. There, the new ToDo is entered into the database and then automatically displayed in our overview.
Open your pages/todos.tsx file again and extend it with a form. Directly above the ToDo list, add a simple input field and a button.
import { useEffect, useState } from "react";
import { useAuth } from "../hooks/AuthProvider";
export default function TodosPage() {
const { user} = useAuth();
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState("");
useEffect(() => {
const fetchTodos = async () => {
if (!user) return;
const token = await user.getIdToken();
const response = await fetch("http://your-domain.com/todos", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
setTodos(data);
};
fetchTodos();
}, [user]);
const handleAddTodo = async (e: React.FormEvent) => {
e.preventDefault(); // prevent page reload
if (!newTodo.trim()) return; // ignore empty input
const token = await user.getIdToken();
const response = await fetch("http://your-domain.com/todos", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title: newTodo }),
});
if (response.ok) {
const createdTodo = await response.json();
setTodos([...todos, createdTodo]); // add new ToDo to the list
setNewTodo(""); // clear input field
}
};
return (
<div>
<h1>Your ToDos</h1>
<form onSubmit={handleAddTodo}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Enter new task"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo: any) => (
<li key={todo.id}>
{todo.title} {todo.completed ? "✅" : "❌"}
</li>
))}
</ul>
</div>
);
}
First, we use useState to create a new variable newTodo. We always store the current value from the input field in it. Whenever the user types something, we update this value with setNewTodo.
When the user submits the form (either by pressing Enter or clicking the button), the handleAddTodo function is executed. Here’s what happens:
We use e.preventDefault() to prevent the browser from reloading the page, which it would normally do when submitting a form.
We check if any text was actually entered. If the field is empty, we abort.
We again get the user’s auth token so the backend knows this request is valid.
We make a POST request to the /todos endpoint. This means we send data to the server to create a new ToDo. In the body, we put a JSON with the title of the new ToDo.
If the server responds successfully, we get the created ToDo back. We add this to our existing list (setTodos([...todos, createdTodo])), so the user immediately sees the new task added.
Finally, we reset the input field so the user can enter the next task right away.
You can now log in to your app and go to the /todos page. There, you should now be able to create new tasks via the input field. Each new task is sent directly to the backend, stored in the database, and immediately appears in your list.
If you reload the page, your tasks should still be there — proof that the connection between frontend, backend, and database works.
We’ve now achieved a really important step: your website can not only display data, but also create and save new entries. That’s what makes an app come alive. In the next chapter, we’ll take care of editing existing ToDos, i.e., marking a task as completed.
Our ToDo list now shows all tasks for the logged-in user. But so far, they’re only passively visible. In this chapter, we’ll finally add the ability to actively change tasks — specifically: mark them as completed or not completed.
This is a small but important step, as it brings real interactivity to our application. The user can work directly with their list without having to open additional pages or dialogs.
In many ToDo apps, you can rename, prioritize, or even color-code tasks.
We’re deliberately keeping it minimalist: our only edit function is that the user can check off a ToDo when it’s done.
Technically, we change the completed field in our ToDo object from false to true — or vice versa if the user unchecks it.
We then send this change via a PATCH request to our backend, which saves the new information in the database.
Let’s first look at how a single task looks in our frontend.
Each ToDo is displayed in the overview with a checkbox that reflects the completed status. If the ToDo is done, the checkbox is checked — otherwise, it’s empty.
Here’s a simple example in React (TypeScript):
<li key={todo.id} className="flex items-center gap-2 py-1">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id, !todo.completed)}
/>
<span className={todo.completed ? "line-through text-gray-500" : ""}>
{todo.title}
</span>
</li>👉 So when the user clicks the checkbox, toggleTodo() is called.
We pass the task’s ID and the new status (the opposite of the current value).
Now comes the core: the function that transfers the new status to the backend.
We want to make sure the change is saved permanently and not just visible in the browser.
Here’s what the function might look like:
const toggleTodo = async (id: string, completed: boolean) => {
try {
const token = await user.getIdToken();
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/todos/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ completed }),
});
// After update: reload list
fetchTodos();
} catch (error) {
console.error("Error updating ToDo:", error);
}
};Let’s go through this briefly:
Get Firebase token:
So the backend knows who’s making the request, we get the logged-in user’s token via user.getIdToken().
Send PATCH request:
With the fetch() function, we send an HTTP request to our NestJS backend.
The /todos/:id endpoint expects the ID of the task to be changed.
In the request body, we send the JSON { completed: true } or { completed: false }.
Reload list:
After the change, we call fetchTodos() to reload the current ToDo list. This ensures the UI shows the latest state.
Error handling:
If something goes wrong (e.g., no internet connection or expired token), an error message is output to the console.
So the user immediately sees that their action was successful, we can add a little visual feedback in the frontend.
For example, we can strike through the title when the task is done, or add a subtle color change.
<span
className={`transition-colors duration-200 ${
todo.completed
? "line-through text-gray-500"
: "text-black"
}`}
>
{todo.title}
</span>The combination of strikethrough text and gray color makes it clear at a glance which tasks are already checked off.
In our current version, we update the UI only after the server responds.
This is safe, but can feel a bit “sluggish.”
A modern approach is optimistic updates: we change the state in the UI immediately, before the response arrives — and only correct if there’s an error.
It would look like this:
const toggleTodo = async (id: string, completed: boolean) => {
const updatedTodos = todos.map(todo =>
todo.id === id ? { ...todo, completed } : todo
);
setTodos(updatedTodos);
try {
const token = await user.getIdToken();
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/todos/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ completed }),
});
} catch (error) {
console.error("Error updating:", error);
fetchTodos(); // If error → reload data
}
};This makes everything feel smoother, as the user sees the change immediately.
If you now start your application, log in, and click the checkbox next to a ToDo, it should instantly be marked as completed.
In the background, your website sends a request to the backend, which saves the change in the database. If you reload the page, the status remains.
You now have an interactive and functional ToDo list where the user can check off tasks directly — an important milestone on the way to a complete, connected application.
Our ToDo list can now be created, displayed, and even edited.
But in real life, tasks aren’t just completed — sometimes we just want to delete them.
Maybe the task was just a test, or it’s no longer relevant.
In this chapter, you’ll learn how to let your users permanently remove ToDos from the database — safely, transparently, and easily.
Before we start coding, let’s briefly look at what “deleting” means technically.
When the user clicks the delete button, a DELETE request is sent to our NestJS backend.
There, the server checks whether the user is even authorized to delete this task (i.e., whether it really belongs to them).
If so, the entry is removed from the database.
Afterwards, the website should automatically reload the ToDo list so the deleted task disappears.
This sounds complicated, but in practice it’s just a small addition to what you’ve already built.
We start again in the frontend, in our ToDo list.
Next to each task, we now add a small button with a 🗑️ symbol or simply the text “Delete.”
It could look like this:
<li key={todo.id} className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id, !todo.completed)}
/>
<span className={todo.completed ? "line-through text-gray-500" : ""}>
{todo.title}
</span>
</div>
<button
onClick={() => deleteTodo(todo.id)}
className="text-red-500 hover:text-red-700 transition-colors"
>
Delete
</button>
</li>👉 Now you have a button for each ToDo to delete it.
All that’s missing is the deleteTodo() function, which we’ll create next.
Similar to editing (PATCH), we now send a request to our backend — this time with the DELETE method.
Again, we need to authenticate the user via the Firebase token so no one can delete someone else’s tasks.
Here’s what the function looks like:
const deleteTodo = async (id: string) => {
const confirmed = window.confirm("Do you really want to delete this ToDo?");
if (!confirmed) return;
try {
const token = await user.getIdToken();
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/todos/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
});
// After deleting, reload list
fetchTodos();
} catch (error) {
console.error("Error deleting ToDo:", error);
}
};Let’s go through this step by step:
Confirmation prompt:
Before we actually delete the ToDo, we ask with window.confirm().
This prevents the user from accidentally clicking “Delete” and losing important tasks.
Get token:
Again, we get the Firebase token via user.getIdToken() so the backend knows which user is making the request.
DELETE request:
We send an HTTP request to the /todos/:id endpoint, where :id is the unique ID of the ToDo. We already set up this endpoint in the last article of the series.
Update list:
After successful deletion, we call fetchTodos() to reload the current list, so the deleted ToDo no longer appears.
Error handling:
If there’s a problem (e.g., no connection or invalid token), an error message is output to the console.
Optionally, you can also give your user a little feedback when deletion is successful — for example, with a short message or a green notification.
alert("ToDo successfully deleted!");Or, a bit more modern, you can use a state variable to display a small temporary notification, e.g., “Task removed ✅.”
If you now start your application, you should try the following:
Log in
Create one or more ToDos
Delete one of them
After clicking “Delete,” the confirmation prompt appears.
If you confirm, the ToDo is removed from the database and instantly disappears from the list.
You now have a fully functional CRUD app (Create, Read, Update, Delete) — with clean Firebase authentication, your own database, and a modern frontend in NextJS.
You’ve successfully brought your ToDo website to life! 🎉
Your users can now securely log in via Firebase Authentication, create their own tasks, view them, mark as completed, and delete them — all with a modern NextJS interface that communicates directly with your NestJS backend.
What you’ve built here is more than just a small demo: it’s a fully-fledged, scalable workflow that you can reuse for any kind of project — whether for notes, projects, or team boards.
The next step is to make the whole thing mobile-friendly.
So we’ll focus on the Flutter app, which uses the same API and lets you manage ToDos directly from your smartphone. 🚀
Comments
Please sign in to leave a comment.