Tutorials

Making a Twitter-like backend in < 30 minutes using Neutrino

Getting Started

This tutorial is for an outdated version of Neutrino but most of the concepts are still applicable in the current version.

 

What is the MERN Stack

MERN stack refers to MongoDB, Express, React, and Node: Mongo - A popular no-sql database program Express - A backend JavaScript web application framework React - A front-end JavaScript library for building user interfaces Node - An open source JavaScript runtime environment

 

MVC Architecture

MVC is an architectural pattern for building software and web applications that consists of 3 parts, the Model, the View, and the Controller

Model - handles all data logic and directly interacts with the database. In this case, we will be using MongoDB and Mongoose, which is a library built on top of Mongo that we will use to define our model schema and interact with our Express server View - handles all client side logic, this is the React side of the application and will be what the user interacts with Controller - acts as an interface between the Model and View. It processes all requests, fetches data from the Model to send to the View, and takes in information from the View to update the Model

 

Getting Started

Neutrino uses MongoDB to power its database, in this tutorial, we will be using MongoDb Atlas, but you could run MongoDB locally as well if you wanted. We won't go over how to set up a new MongoDB Atlas cluster and database, but you can follow this tutorial to get started.

  • Once you've created your cluster and set up your database, you're going to want to get your connection string.

  • You're also going to want to make sure you have Node js installed to run your application.

 

Setting Up the Models

First, let's start a new Neutrino project at app.neutrinojs.dev

If you're curious and want to check out their documentation, you can find it at neutrinojs.dev/docs

User

We want users to be able to have a name, username, and bio, as well as be able to register to our app, which will require us to define email and password parameters as well.

So, our params will be:

  • name - string
  • username - string
  • email - string
  • password - string
  • bio - text

note: Here, we differentiate string from text, but text is nothing more than a string without character limits. It will also default to a textarea component in React.

Post

We want users to be able to make posts, and posts to contain a few different parameters, such as the number of likes, title, content, and comments.

  • We could build comments right into the post, but it would be much better practice to separate them out into their own model with their own CRUD functionality.
  • Similarly for likes, we could build them right into a number parameter for post, but what if we want to access the people who liked the post? Or get a list of all the posts a user has liked? We'd need more than a simple number keeping track of the number of times a user has pressed 'like'. We will go over this later.

Therefore, our data parameters will look like this:

  • title - string
  • content - text

Comment

We want users to be able to comment on different posts, and we want these comments to be associated with the user that posted them.

Therefore, out data parameters will look like this:

  • content - string
  • user - string (actually, this will be a Mongoose id, but we will discuss this later)

 

Defining Model Relations

When we discuss model relations, we're going to bring up terminology such as one-to-many or many-to-many, these are terms typically used in SQL databases, and the meaning doesn't really apply in the same way as it would in an SQL database. Nevertheless, they are still effective at conveying the hierarchy of our models and how they will interact with each other.

  • We want users to be able to make posts, therefore, a user will be able to have many posts, but a post will belong to only one user. This is a one-to-many relationship between users and posts.

  • We also want users to be able to comment on different posts. Therefore, a post can have many comments, but a comment can only belong to one post. This again, is a one-to-many relationship between posts and comments.

  • By extension, we can also represent a one-to-many relationship between users and comments, however, Neutrino currently doesn't support a multiple one-to-many relationships for the same 'many' model, so we will just have to do this manually.

To summarize:

  • a user has many posts
  • a post belongs to a user
  • a post has many comments
  • a comment belongs to a post

 

Implementing in Neutrino

Step 1) Create a new model and name it User (by convention Neutrino requires you to name your models as singular nouns)

new model page

 

Adding Data Parameters

Step 2) Click on 'authentication', which will automatically create the username, email, and password parameters, and manually pass in the name:string and bio:text params by clicking on 'ADD PARAM'

add param to model

Step 3) Create the Post and Comment models and pass in their required data parameters that we specified before. So for Post, it would be title:string and content:text, and for Comment, it would be content:string and user:string. After doing this, your models page should look like this:

models page with data params

 

Implementing Model Relations

Step 4) We said we wanted two one-to-many relationships, one between User and Post, and one between Post and Comment.

  • We can do this by passing a has_many: Post param for User and a belongs_to: User param for Post.

add relationship to model

After doing this for Post and Comment, your models page should look like this:

models page with relationships

And well, the Relations page doesn't really do much yet, but if you did everything correctly, it should look like this:

relations page

 

Routing

Step 5) We enabled authentication by defining User as an authObject in the Models page, now we want to specify which routes we actually want and which ones we want to protect.

  • Lets head to the Routes page, which should originally look like this:

routes page

Neutrino scaffolds create all of the RESTful routes for each model by default, so for user it would be index, show, create, update, destroy, etc.

  • Note that the new, and edit routes are created only in the frontend, they simply render a form and don't actually call the backend until you hit submit. (with the exception that edit actually makes a GET request to load all the current model info).

 

Disabling Unnecessary Routes

Step 5a) We clearly don't want each route available for every model, so let's start out by disabling a couple.

  • We don't really want users to be able to access a list of all comments ever created so lets disable the index route for Comment
  • We also don't need an individual page to display a singular comment so we can go ahead and disable the show route for for Comment
  • And finally, let's say we don't want Users to be able to modify a comment after commenting, so let's disable the update route for Comment (note that this automatically disables the edit route too).

Your Comment routes should now look like this:

comment routes

 

Route Protection

Step 5b) By enabling route protection, we are enabling two things:

  • The verifyJWT middleware in the backend, which will make sure the user is authenticated before enabling them access to the route.
  • The PrivateRoute component in the frontend, which will automatically redirect the user to the login page if they're not authenticated.

We can split all routes into two main categories: public routes, accessible to anyone regardless of whether or not they're signed in, and private routes, which should only be accessible to logged in users.

  • We want users to be able to see all posts and be able to click on a post to see its comments even if they're not logged in, so we can leave both the Post index and show routes as public.
  • We also want unauthenticated users to be able to create a new user (by registering an account), so we can leave User create as public too.
  • However, we want Users to be authenticated to do anything else.
  • Let's protect all other routes by clicking on the protected lock icon.

Your routes should look like this:

users routes protected

posts routes protected

comments routes protected

 

Route Logic

Step 5c) Neutrino has a pretty neat feature of offering route logic templates for certain routes, these can be anything from hiding certain parameters such as passwords on GET requests, to verifying to see if a user is trying to modify another user's content.

Lets look at these route by route:

  • User show:
    • A GET request to User will return all of the user's parameters by default (the password will be hashed but we still don't need other users to see this).
    • Lets enable the protect info logic template by clicking on the gear button and then on protect info to automatically hide the password field for the logged in user and the password and email field for anyone else (even if a user is signed in, we don't want them to access another user's email).
    • You could also hide other parameters if you wanted, so if you didn't want other users to access the name parameter, you could pass that into hide as well.
    • Make sure to hit 'SAVE'.

user show logic

  • User update:
    • We clearly don't want users to edit other users' information so lets enable logic and click on the protect update template.

user update logic

  • User delete:
    • We don't want users to be able to delete other users' accounts so lets enable logic and click on the protect action template.

user delete logic

Let's understand what we just did:

  • req.user.id: Refers to the ID that is associated with the currently authenticated user making the request. This only works if VerifyJWT was enabled for this particular route.
  • data._id.toString(): The data object is the object that we are trying to access from the database. We are then accessing the data's (which is of type User) _id parameter. Lastly we have to convert the _id object into a string, so we use toString().
  • hide: Refers to a special shorthand that removes certain keys from the response object. In our example in the if statement we try to hide password and email, so on the user side when the response object is received the response will never contain the password, as it is sensitive information, and will only include the email if the user fetched is the same user making the request.
  • error: Error is a special shorthand to send a 500 response to the user with the given Error message after the = sign. So if we wanted to send an error with a different message, “Not Nice”, we could replace the error line with error=Not Nice.

Now for Post:

  • Post: create
    • When a user creates a new Post, we don't want them to be able to modify the id of the user that created it as this would essentially be impersonating another user. So let's enable route logic and click on the protect create template.

create post logic

  • Post: update
    • We obviously don't want users editing other users' posts.
    • We also don't want a user to be able to modify the user parameter for a post (even if it is their own) because this would essentially be impersonating another user. Let's enable route logic and click on the protect update template.

post update logic

  • Post: delete
    • We don't want users to be able to delete another user's post, so lets pass in some route logic.

post delete logic

Now for Comment

  • Comment: create
    • Neutrino actually doesn't provide any templates for this route since we didn't specify a one to many with the authObject (User), but we can use what we just learned about routes to do the same thing.
    • Since we don't want users to be able to make comments on behalf of another user.
if (req.user.id != req.body.user) {
    error=Incorrect parameters
}

Comment create logic

  • Comment: delete
    • Since we don't want users to be able to delete other users' comments.
if (req.user.id != data.user) {
    error=Cannot delete another users comment
}

comment delete logic

Lets understand what this is doing:

Remember that we're passing user:String as a parameter when creating a comment. That means that we're storing the id of the user that created the comment. As such, we can compare it with the id of the user making the request through req.user.id to see if the user making the request is the same user that created the comment.

 

Rewind

We still haven't discussed how we will implement likes. This is partially by design since I didn't want to intimidate beginners with too much information, but now you've made it this far so let's implement likes.

  • Likes will be a many-to-many relationship between User and Post (Even though we previously declared them to have a one-to-many relationship, they now have both).

  • That is, a user can like many posts, and a post can have likes from many users.

 

Implementing Likes

Step 6) Lets go back to the Models page and add another has many: Post param for User and a has many: User param for Post

Your Models page should look like this: new models page

Your Relations page should look like this: new relations page

Note that Neutrino automatically adds two new routes for Many-to-Many relationships:

addPost and dropPost

add and drop Post routes

addUser and dropUser

add and drop User routes

These routes will be helpful since they automatically provide the logic to add a post to the user's liked array and a user to a post's liked_by array (we can change the name of the route methods later if we want).

 

Adding Mongo Connection String

Step 7) Go to the Settings page and add your MongoDB Connection string if you have it. You could also do this later, you'll just have to insert it in the index.js page of your server before you can run your application.

For help accessing your MongoDB Atlas Connection string, follow this guide

 

Saving Your Projects

Neutrino lets you create an account to save your projects which may be helpful in debugging or adding new features as your project grows. However, this is fully optional.

 

Export

Step 8) Click on the EXPORT button on the sidebar and add a project name and your email and you're done! If you followed along correctly, Neutrino should download a zip folder containing all of your project's code

You're Done (almost)!

 

Running Your Code

Extract the zip folder and open it up in your editor. Run the following commands in this order. cd server npm i node index.js note: If you haven't added a Mongo Connection String, you'll get the following error:

throw new MongoParseError('Invalid scheme, expected connection string to start with "mongodb://" or "mongodb+srv://"');

On a new terminal, run: cd client npm i npm run start

If everything went correctly, you should see the following page:

welcome page

Nothing quite too interesting yet, but you can see that you can register a new user then log in with the specified username and password. You can also try to create a new post and comment (if you try to pass in anything other than your own user's id for the user parameter when creating a new comment you should get an error).

However, the whole frontend is pretty generic and we'll get around to fixing it. Let's fix up a couple of things in the backend first though.

 

Model Files

 

User Model

/server/models/User.js

Your code should look like this:

const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema({
	username: {
		type: String,
		required: true
	},
	email: {
		type: String,
		required: true
	},
	password: {
		type: String,
		required: true
	},
	name: {
		type: String,
		required: true
	},
	bio: {
		type: String,
		required: true
	},
    likes: [
		{
			type: mongoose.Schema.Types.ObjectId,
			ref: 'Post'
		}
	]
})

UserSchema.virtual('posts', {
        ref: 'Post',
        localField: '_id',
        foreignField: 'user'
});

UserSchema.set('toObject', { virtuals: true });
UserSchema.set('toJSON', { virtuals: true });

const User = mongoose.model('User', UserSchema);
module.exports = User;

Each object in the schema represents a parameter for the object, likes represents the Many-to-Many association we created with Posts, which is simply an array of Object IDs for different posts.

The latter code in UserSchema.virtual specifies our One-to-Many relationship with Post. Mongoose virtuals allow us to fetch the posts associated with the given user without actually storing them in the User document in the database, which will help performance.

You can read more about Mongoose virtuals here

 

Post Model

/server/models/Post.js

Your code should look like this:

const mongoose = require('mongoose');

const PostSchema = new mongoose.Schema({
	title: {
		type: String,
		required: true
	},
	content: {
		type: String,
		required: true
	},
	user: {
		type: mongoose.Schema.Types.ObjectId,
		ref: 'User',
		required: true
	},
	liked_by: [
		{
			type: mongoose.Schema.Types.ObjectId,
			ref: 'User'
		}
	]
})

PostSchema.virtual('comments', {
	ref: 'Comment',
	localField: '_id',
	foreignField: 'post'
});

PostSchema.set('toObject', { virtuals: true });
PostSchema.set('toJSON', { virtuals: true });

const Post = mongoose.model('Post', PostSchema);
module.exports = Post;

 

User Controller

/server/controllers/UserController.js

Neutrino sometimes messes up the route methods whenever you have different relationships between the same two models (remember how we had both a One-to-Many and a Many-to-Many between User and Post), so make sure your User Controller has these two methods:

addPost: async (req, res) => {
    const { user_id, post_id } = req.params;
    UserModel.findByIdAndUpdate(
      user_id, 
      { $push: { likes: post_id } },
      (err, data) => {
        if (err) {
          res.status(500).send(err);
          console.log(err);
        } else {
          res.status(200).send(data);
          console.log('Post added!');
        }
      }
    )
  },

  dropPost: async (req, res) => {
    const { user_id, post_id } = req.params;
    UserModel.findByIdAndUpdate(
      user_id, 
      { $pull: { likes: post_id } },
      (err, data) => {
        if (err) {
          res.status(500).send(err);
          console.log(err);
        } else {
          res.status(200).send(data);
          console.log('Post dropped!');
        }
      }
    )
  },

Let's also fix up the .populate() function in find() as Neutrino may have written a slight bug.

  • First, we need to populate posts since Mongoose virtuals only gives us the ids of the posts belonging to the given user. The populate function replaces this id with an object containing the actual posts' information, specifically the parameters defined in select

  • We also need to populate likes with the objects corresponding to actual post data

You can read more about Mongoose's populate function here

Your find function should look as follows:

find: async (req, res) => {
    const { id } = req.params;
    try {
      const data = await UserModel.findById(id)
				.populate({ path: 'posts', select: 'title' })
        .populate({ path: 'likes', select: 'title content' })
			if (req.user.id != data._id.toString()) {
			  data.password = undefined;
			  data.email = undefined;
			} else {
			  data.password = undefined;
			}
      res.status(200).send(data);
    } catch (err) {
      res.status(400).send(err.message);
      console.log(err);
    }
  },

 

Post Controller

/server/controllers/PostController.js

Lets rename some variables in the addUser and dropUser methods. In $push and $pull, rename users to liked_by

addUser: async (req, res) => {
    const { post_id, user_id } = req.params;
    PostModel.findByIdAndUpdate(
      post_id, 
      { $push: { liked_by: user_id } },
      (err, data) => {
        if (err) {
          res.status(500).send(err);
          console.log(err);
        } else {
          res.status(200).send(data);
          console.log('User added!');
        }
      }
    )
  },

  dropUser: async (req, res) => {
    const { post_id, user_id } = req.params;
    PostModel.findByIdAndUpdate(
      post_id, 
      { $pull: { liked_by: user_id } },
      (err, data) => {
        if (err) {
          res.status(500).send(err);
          console.log(err);
        } else {
          res.status(200).send(data);
          console.log('User dropped!');
        }
      }
    )
  },

note: Since we renamed the users array to liked_by in the Post model, we'll run into a few errors if we don't also change the naming in PostController.

Make sure find() and index() look like this:

find: async (req, res) => {
    const { id } = req.params;
    try {
      const data = await PostModel.findById(id)
				.populate({ path: 'comments', select: 'content user' })
				.populate({ path: 'liked_by', select: 'username name' })
			
      res.status(200).send(data);
    } catch (err) {
      res.status(400).send(err.message);
      console.log(err);
    }
  },

  all: async (req, res) => {
    try {
      const data = await PostModel.find()
				.populate({ path: 'comments', select: 'content user' })
				.populate({ path: 'liked_by', select: 'username name' })
			
      res.status(200).send(data);
    } catch (err) {
      res.status(400).send(err.message);
      console.log(err);
    }
  },

 

Server Index

The server index page defines all of our RESTful routes and pointes them to the appropriate controller method.

It also includes verifyJWT, a middleware function that checks for a valid JWT token to ensure the user is authenticated.

Including verifyJWT in a route will require the user to be authenticated before calling the controller function.

/server/index.js

Make sure to include verifyJWT for the following routes:

  • addPost
  • dropPost
  • addUser
  • dropUser

Your code should look like this:

const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const jwt = require("jsonwebtoken")

const app = express();
const PORT = 8080;
const corsOptions = {
  origin: "*"
}

app.use( express.json() );
app.use( cors(corsOptions) );

mongoose.connect('<YOUR OWN CONNECT STRING HERE>', {
	useNewUrlParser: true,
});


function verifyJWT(req, res, next) {
  if (!req.headers["authorization"]) {
    return res.status(400).json({ message:"No Token Given", isLoggedIn: false });
  }

  const token = req.headers["authorization"].split(' ')[1];
  if (token) {
    jwt.verify(token, "pleasechange", (err, decoded) => {
      if (err) return res.status(500).json({ message: "Failure to Auth", isLoggedIn: false });
      req.user = {};
      req.user.id = decoded.id;
      req.user.username = decoded.username;
      next();
    })
  } else {
    return res.status(400).json({ message: "Incorrect Token Given", isLoggedIn: false });
  }
}


// CONTROLLERS
const UserController = require('./controllers/UserController');
const PostController = require('./controllers/PostController');
const CommentController = require('./controllers/CommentController');


// ROUTES
app.get('/users', verifyJWT, UserController.all);
app.get('/users/:id', verifyJWT, UserController.find);
app.post('/users', UserController.register);
app.put('/users/:id/edit', verifyJWT, UserController.update);
app.delete('/users/:id', verifyJWT, UserController.delete);
app.post('/users/:user_id/add-post/:post_id', verifyJWT, UserController.addPost);
app.post('/users/:user_id/drop-post/:post_id', verifyJWT, UserController.dropPost);

app.get('/posts', PostController.all);
app.get('/posts/:id', PostController.find);
app.post('/posts', verifyJWT, PostController.create);
app.put('/posts/:id/edit', verifyJWT, PostController.update);
app.delete('/posts/:id', verifyJWT, PostController.delete);
app.post('/posts/:post_id/add-user/:user_id', verifyJWT, PostController.addUser);
app.post('/posts/:post_id/drop-user/:user_id', verifyJWT, PostController.dropUser);

app.post('/comments', verifyJWT, CommentController.create);
app.delete('/comments/:id', verifyJWT, CommentController.delete);

// AUTH
app.post('/login', UserController.login);
app.post('/register', UserController.register);

app.listen(
	PORT,
	console.log("Server running on port 8080...")
);

 

Fixing Up the Front End

Each Model comes with 4 pages built in corresponding to each of the CRUD functions

  • [ModelA]s.js : an index page containing a list of all [ModelA]s created
  • [ModelA]Show.js : a page displaying all information corresponding to a single [ModelA]
  • [ModelA]Edit.js : a page rendering a form to update a specific [ModelA]
  • [ModelA]New.js : a page rendering a form to create a new [ModelA]

 

Display User Page

/client/src/Pages/User/UserShow

UserShow.js renders a pretty generic page, lets change a few things to make it look more like a profile page.

Displaying Params

You can change the header to greet the user with their username rather than id, also, since we added logic for hiding the user's email and password, you can delete the password parameter and add a conditional to only render email if its not null.

Conditional Rendering

As for the EDIT and DELETE buttons, we only want to display them if the currently authenticated user is the same user we're displaying.

To do so, first import useContext from react and include the following lines:

import { UserContext } from '../../hooks/UserContext';

...
export default function UserShow(props) {
  const { authUser } = useContext(UserContext);

Now, we can access the signed in user if it exists by simply calling authUser

Wrap both buttons with the following conditional:

{ authUser._id === id && 
          <div>
            <Button variant="outlined" style={{marginRight: 15}}
              onClick={() => navigate(`/users/${id}/edit`)}>edit
            </Button>
            <Button variant="contained" color="error" 
              onClick={handleDelete}>delete
            </Button>
          </div>
}
Displaying Liked Posts

We can display liked posts by simply calling the user.likes array.

It might look something like this:

<div className='displayContainer'>
	<h3>Liked Posts</h3>
	<ul>
	{user.likes && user.likes.map((post, i) => (
		<div className='listItem' key={i}>
			<li>{post.title}</li>
			<Button variant='outlined' size='small'
        onClick={() => navigate(`/posts/${post._id}`)}>show</Button>
		</div>
	))}
	</ul>
</div>

 

Display Post Page

/client/src/Pages/Post/PostShow

Again, this page is currently pretty generic, but we can fix it up a bit by changing the header and how we display some of the params.

What's a bit more interesting though is how we're dealing with likes.

Liking Posts

Change the addUser and dropUser functions to the following:

function likePost() {
    try {
      axios.post(`http://localhost:8080/posts/${id}/add-user/${authUser && authUser._id}`,
				{}, { headers: authHeader() });
      axios.post(`http://localhost:8080/users/${authUser && authUser._id}/add-post/${id}`,
				{}, { headers: authHeader() });
    } catch (e) {
      console.log(e);
    };
    window.location.reload();
  }

  function unlikePost(droppedId) {
    try {
      axios.post(`http://localhost:8080/posts/${id}/drop-user/${authUser && authUser._id}`,
				{}, { headers: authHeader() });
      axios.post(`http://localhost:8080/users/${authUser && authUser._id}/drop-post/${id}`,
				{}, { headers: authHeader() });
    } catch (e) {
      console.log(e);
    };
    window.location.reload();
  }

All we're doing is changing the name of the function for readability and changing the user id to the id of the currently authenticated user (This will require you to import useContext UserContext define authUser like we did in UserShow).

Conditional Rendering

If we only want to display the edit and delete buttons if the post belongs to the authenticated user, wrap the buttons in the following conditional:

{ post.user === authUser._id &&
          <div>
            <Button variant="outlined" style={{marginRight: 15}}
              onClick={() => navigate(`/posts/${id}/edit`)}>edit
            </Button>
            <Button variant="contained" color="error" 
              onClick={handleDelete}>delete
            </Button>
          </div>
          }
Displaying Like/Unlike Button

This button will render depending on whether or not the currently authenticated user has already liked the post.

Therefore, we can create two new buttons for liking and unliking and wrap them in the following ternary opertor:

{ (post.liked_by && post.liked_by.some(user => user._id === authUser._id)) ?
          <Button variant="contained" color="error" 
            onClick={unlikePost}>unlike
          </Button>
          :
          <Button variant="contained" color="success" 
            onClick={likePost}>like
          </Button>
        }

Lets understand what this is doing:

  • post.liked_by is the array of users that have liked this post
  • .some((user) => condition) returns true if any user matches the following condition
    • In this case, we want to return true if the currently authenticated user has liked the post, that is, if authUser is included in the posts liked_by array
    • If true, we want to display the unlike button, otherwise, display the like button

 

Finishing Thoughts

Okay there's a chance after reading everything and making the slight changes this project took a little over 30 minutes. But really, we had the bulk of our functionality up and running in only a couple of minutes due to Neutrino.

There's obviously a lot more that can be done fixing up the frontend and customizing it to look more like an actual blog app, but hopefully after following these examples with UserShow and PostShow, you gathered enough on your own to get started with the rest.

Happy Coding!

Previous
Neutrino News