DEV Community

Cover image for Next.js and MongoDB full-fledged app Part 2: User profile and Profile Picture
Hoang
Hoang

Posted on • Updated on • Originally published at hoangvvo.com

Next.js and MongoDB full-fledged app Part 2: User profile and Profile Picture

This is a follow-up to Part 1. Make sure you read it before this post. Today, I will showcase how I add editable user profile and profile Picture.

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
  • Using Next.js v9 API Routes w/ middleware

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;
Enter fullscreen mode Exit fullscreen mode

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 });
  }
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

(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();
Enter fullscreen mode Exit fullscreen mode

Adding into our existing onSubmit function:

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

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;
Enter fullscreen mode Exit fullscreen mode

Looking at:

export const config = {
  api: {
    bodyParser: false,
  },
};
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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);
  }
);
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

Uploading an image is as simple as:

cloudinary.uploader.upload("theImagePath");
Enter fullscreen mode Exit fullscreen mode

...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
});
Enter fullscreen mode Exit fullscreen mode

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

Let's run our app and test it out. We have managed to create our user profile functionality with profile picture.

Again, check out the repository nextjs mongodb app.

If you find this helpful, consider give the the repo a star to motivate me to add more content.

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

Discussion (2)

Collapse
eeeman1 profile image
eeeman1

api: {
bodyParser: false,
}

not working on heroku - 2020-04-29T10:51:35.725587+00:00 heroku[router]: sock=backend at=error code=H18 desc="Server Request Interrupted" method=PATCH path="/api/upload" host=eeeman-masterclass.herokuapp.com request_id=de89640c-a9f0-4dd1-bb7c-b64d0a0239e5 fwd="109.252.129.15" dyno=web.1 connect=0ms service=286ms status=503 bytes=180 protocol=https

Collapse
fredestrik profile image
Frédéric Lang

Hi,
I build an app closed to nextjs-mongodb-app with SQL and Bulma CSS.
check it out at nextjs-sql-app.vercel.app
github repo : github.com/Fredestrik/Next.Js-SQL-app