React, Next.js, MongoDB, Tutorial

Next.js and MongoDB full-fledged app Part 2

User profile and Profile Picture

User profile is a common component found in most social media application. This article will show the implementation of editable user profile (with profile picture upload).


Again, below are the Github repository and a demo for this project to follow along.

Github repo

Demo

About nextjs-mongodb-app project

nextjs-mongodb-app is a Full-fledged serverless app made with Next.JS and MongoDB

Different from many other Next.js tutorials, this:

  • Does not use the enormously big Express.js, supports serverless
  • Minimal, no fancy stuff like Redux or GraphQL for simplicity in learning
  • Using Next.js latest features like API Routes or getServerSideProps

For more information, visit the Github repo.

What we are making

Edit Profile Page

(The GIF above is actually from an older version. Looks super bare-bone 😜)

We are adding the following features:

  • Profile Page
  • Edit Profile
  • Profile Picture

The user profile page

Profile Page

My user profile page will be at /user/my-username. Let's create /pages/user/[username]/index.jsx so we can dynamically show user profile based on the username param.

import { database } from "@/api-lib/middlewares";
import nc from "next-connect";
import Head from "next/head";
const ProfilePage = ({ user }) => {
return (
<>
<Head>
<title>
{user.name} (@{user.username})
</title>
</Head>
<div>
<img
src={user.profilePicture}
width="128"
height="128"
alt={user.name}
/>
<h1>
<div>{user.name}</div>
<div>@{user.username}</div>
</h1>
</div>
</>
);
};
export async function getServerSideProps(context) {
await nc().use(database).run(context.req, context.res);
const user = await req.db
.collection("users")
.findOne(
{ username: context.params.username },
{ projection: { password: 0, email: 0, emailVerified: 0 } }
);
if (!user) {
return {
notFound: true,
};
}
user._id = String(user._id); // since ._id of type ObjectId which Next.js cannot serialize
return { props: { user } };
}
export default ProfilePage;

For the above, we use getServerSideProps to retrieve the user data from the database. Our database middleware is used to load the database into req.db. This works because getServerSideProps is run on the server-side.

Then, we call MongoDB findOne() to retrieve the user by the username from params (context.params.username). You can also notice that we filter out the sensitive fields via projection.

If the user is found, we return it as a prop. Otherwise, we return the not found page by setting notFound to true.

Our page component would receive the user prop as to render his or her information.

The Profile Setting page

Building the Profile Update API

The way for our app to update the user profile is would be to make a PATCH request to /api/user.

In pages/api/user/index.js, we add a handler for PATCH:

import { auths, database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";
import slug from "slug";
const handler = nc();
handler.use(database, ...auths);
handler.patch(
validateBody({
type: "object",
properties: {
username: { type: "string", minLength: 4, maxLength: 20 },
name: { type: "string", minLength: 1, maxLength: 50 },
bio: { type: "string", minLength: 0, maxLength: 160 },
},
}),
async (req, res) => {
if (!req.user) {
req.status(401).end();
return;
}
const { name, bio } = req.body;
if (req.body.username) {
username = slug(req.body.username);
if (
username !== req.user.username &&
(await req.db.collection("users").countDocuments({ username })) > 0
) {
res
.status(403)
.json({ error: { message: "The username has already been taken." } });
return;
}
}
const user = await db
.collection("users")
.findOneAndUpdate(
{ _id: new ObjectId(id) },
{
$set: {
...(username && { username }),
...(name && { name }),
...(typeof bio === "string" && { bio }),
},
},
{ returnDocument: "after", projection: { password: 0 } }
)
.then(({ value }) => value);
res.json({ user });
}
);

We first validate the body using our validateBody middleware. Then, we check if the user is logged in by checking req.user. If not, it will send a 401 response.

If a username is provided, we will slugify it and check if exists in the database. Finally, we call MongoDB findOneAndUpdate to update the user profile based on the data from req.body.

We then return the updated user document.

The Profile Settings Page

Profile Settings Page

The next thing to do is have page at /settings for us to update our info.

Let's create pages/settings.jsx

import { useCurrentUser } from "@/lib/user";
import { fetcher } from "@/lib/fetch";
import { useRouter } from "next/router";
import { useEffect, useCallback } from "react";
const AboutYou = ({ user, mutate }) => {
const usernameRef = useRef();
const nameRef = useRef();
const bioRef = useRef();
const onSubmit = useCallback(
async (e) => {
e.preventDefault();
try {
const formData = new FormData();
formData.append("username", usernameRef.current.value);
formData.append("name", nameRef.current.value);
formData.append("bio", bioRef.current.value);
const response = await fetcher("/api/user", {
method: "PATCH",
body: formData,
});
mutate({ user: response.user }, false);
} catch (e) {
console.error(e.message);
}
},
[mutate]
);
useEffect(() => {
usernameRef.current.value = user.username;
nameRef.current.value = user.name;
bioRef.current.value = user.bio;
}, [user]);
return (
<form onSubmit={onSubmit}>
<input ref={usernameRef} placeholder="Your username" />
<input ref={nameRef} placeholder="Your name" />
<textarea ref={bioRef} placeholder="Your bio" />
<button type="submit">Save</button>
</form>
);
};
const SettingsPage = () => {
const { data, error, mutate } = useCurrentUser();
const router = useRouter();
useEffect(() => {
if (!data && !error) return; // useCurrentUser might still be loading
if (!data.user) {
router.replace("/login");
}
}, [router, data, error]);
if (!data?.user) return null;
return <AboutYou user={data.user} mutate={mutate} />;
};
export default SettingsPage;

First of all, the settings page should only be available to authenticated users only. Therefore, if the current user is not available, we want to navigate to /login, which I do so using router and our useCurrentUser hook.

For the update form, we simply create an onSubmit function that collects the inputs and makes a PATCH request to our just created API at /api/user.

Every time the user prop is updated, we need to set the values of the inputs accordingly, which I do so inside the above useEffect.

One thing to note is that we use FormData to send our fields instead of the regular application/json. The reason for this is that it allows us to include our profile picture later, which can be conveniently transmitted via FormData, in the same request.

Upon receiving a successful response, we call mutate to update the SWR cache.

Building the Profile picture functionality

To have this functionality, we need somewhere to host our images. I choose Cloudinary to host my images, but you can use any service.

Add profile picture to the settings page

In the same form above, we add our profile picture field:

<input type="file" accept="image/png, image/jpeg" ref={profilePictureRef} />

(note: the screenshot actually above puts this input in front of an image to achieve the effect as seen, see the source code)

This field has a ref of profilePictureRef, allowing us to access its value:

const profilePictureRef = useRef();

Adding into our existing onSubmit function:

/* ... */
if (profilePictureRef.current.files[0]) {
formData.append("profilePicture", profilePictureRef.current.files[0]);
}

If the user did select an image, we can access its value in profilePictureRef.current.files[0] (files is an array because it can be a multi-file upload) and add it to our FormData instance.

It will be included in the same PATCH request.

Building the Profile Picture Upload API

Since our profile picture is submitted to the same PATCH endpoint. Let's edit its handler.

To handle images, we need something to parse the uploaded file. Multer is the package that we will use.

Let's take a look at our PATCH handler again:

import { auths, database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";
import slug from "slug";
import multer from "multer";
const upload = multer({ dest: "/tmp" });
const handler = nc();
handler.use(database, ...auths);
handler.patch(
upload.single("profilePicture"),
validateBody({
type: "object",
properties: {
username: { type: "string", minLength: 4, maxLength: 20 },
name: { type: "string", minLength: 1, maxLength: 50 },
bio: { type: "string", minLength: 0, maxLength: 160 },
},
}),
async (req, res) => {
/* ... */
}
);
export const config = {
api: {
bodyParser: false,
},
};
export default handler;

Looking at:

export const config = {
api: {
bodyParser: false,
},
};

I am disabling Next.js 9 body-parser because form parsing is already handled by Multer.

We initialize an instance of Multer that is configurated to save the file to our temp folder:

const upload = multer({ dest: "/tmp" });

The instance itself is a middleware, so we attach it before our main handler in the PATCH handlers. The middleware expects a single file upload under the profilePicture field that we specified earlier in our form submission function. Now, we can access the file via req.file.

handler.patch(
upload.single("profilePicture"),
validateBody({
/* ... */
}),
async (req, res) => {
console.log(req.file);
}
);

Integrate Cloudinary

This is the section for the file uploading logic. The content in this section depends on the File Uploading library or service you choose. I am using Cloudinary in my case.

If you use Cloudinary, go ahead and create an account there.

Cloudinary provides its Javascript SDK.

To configure Cloudinary, we need to set the following environment variable:

CLOUDINARY_URL=cloudinary://my_key:my_secret@my_cloud_name

The Environment variable value can be found in the Account Details section in [Dashboard](https://cloudinary.com/console "Cloudinary Dashboard). (Clicking on Reveal to display it)

If you use Cloudinary, look at its Node.js SDK documentation for more information.

Import the cloudinary SDK (Using its v2):

import { v2 as cloudinary } from "cloudinary";

Uploading an image is as simple as:

cloudinary.uploader.upload("theImagePath");

...where out image path is req.file.path.

let profilePicture;
if (req.file) {
const image = await cloudinary.uploader.upload(req.file.path, {
width: 512,
height: 512,
crop: "fill",
});
profilePicture = image.secure_url;
}
const user = await updateUserById(req.db, req.user._id, {
...(username && { username }),
...(name && { name }),
...(typeof bio === "string" && { bio }),
...(profilePicture && { profilePicture }), // <- set the url to our user document
});

We are uploading our image to Cloudinary with the option of cropping it down to 512x512. You can set it to whatever you want or not have it at all. If the upload is a success, I set the URL (the secured one) of the uploaded image to our user's profilePicture field. See cloudinary#upload for more information.

Awesome, we have managed to create our Profile Picture functionality.

Conclusion

We have managed to create our User Profile with Profile Picture functionality.

I hope this has been helpful in working with your Next.js app. Again, check out the repository nextjs-mongodb-app. If you find this helpful, consider giving it a star to motivate me with further development and more content.

Good luck on your next Next.js + MongoDB project!