Building an Ice Cream App: A Full-Stack Tutorial Using Express, Mongo/Mongoose, and React - Part 2/2
Watch out Ben & Jerry's, there's a new ice cream chef in town and it's powered by AI! 🍦🤖

Building an Ice Cream App: A Full-Stack Tutorial Using Express, Mongo/Mongoose, and React - Part 2/2

Welcome to the second part of our tutorial on building an Ice Cream App from scratch! In the first part, we covered the backend side of things, using Express and Mongo/Mongoose to create a RESTful API for managing ice cream orders. Now, it's time to focus on the frontend and build a user interface that connects to our backend API.

In this tutorial, we'll be using React to create a modern, responsive web application that allows users to browse ice cream flavors, place orders, and see their order history. We'll also be using Sass for styling and React Router for navigation. By the end of this tutorial, you'll have a full-stack web app that you can customize and deploy to a live server. So let's get started and build an awesome ice cream app together!

Setup

To set up your project, first, navigate to the frontend folder in your terminal. Then, install React Router and Sass by running the command:

 npm install react-router-dom sass         

Next, create a file named styles.scss in the src folder.

React-Router allows us to generate routes, loaders, and actions, which we can use in our application:

  • Routes: A component that render when navigate to a particular URL
  • Loaders: A function to get data that runs before a route loads and can be used in a component with the useLoaderData hook
  • Actions: Functions that run if a Formcomponent is submitted to a particular route.

To keep track of these create three files in your src folder

  • router.js
  • loaders.js
  • actions.js

Now, let's move on to the router.js file where we can set up our router. This file is where we'll define the routes for our application and specify which components should be rendered for each route.

import {
  createBrowserRouter,
  createRoutesFromElements,
  Route,
} from "react-router-dom"
import App from "./App"

const router = createBrowserRouter(
  createRoutesFromElements(<Route path="/" element={<App />}></Route>)
)

export default router        

Update index.js to look like this:

import React from "react"
import ReactDOM from "react-dom/client"
import "./styles.scss"
import reportWebVitals from "./reportWebVitals"
import { RouterProvider } from "react-router-dom"
import router from "./router"

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
)

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()        

  • Inside src folder, create a components folder and a pages folder.
  • Within the components folder, create a Header.js file.
  • Within the pages folder, create an Index.js file and a Show.js file.
  • Write the component boilerplate code in each of these files.
  • Export each component from its respective file.

function Component(props) {
  return <h1>Component Name</h1>
}

export default Component        

Testing at Every Step

Testing is an essential part of software development that ensures you don't end up with a wobbly 1000-story building. Imagine building your dream skyscraper, one floor at a time, but you're in a hurry to reach the top. You keep adding more floors without checking the foundation or the structure, until you finally reach the 1000th floor and suddenly, your building starts shaking. You have no idea where to begin fixing it and it feels like you're stuck in a Jenga game gone wrong. This is where testing comes in! By testing early and often, you can make sure that each floor is sturdy and secure before moving on to the next one.

No alt text provided for this image
Testing - The Foundation of Your Software Skyscraper: Don't Let it Tumble Down Like a Jenga Game Gone Wrong!

App.js

Our desired component Architecture:

-> App
  -> Header
  -> Outlet
      -> Route |path: "/"|
        -> Index |loads all ice creams|
      -> Route |path="/icecreamse/:id|
        -> Show |loads single ice cream|
      -> Route |path: "/create"| Action to create ice creams
      -> Route |path: "/update/:id"| Action to update ice creams
      -> Route |path: "/delete/:id"| Action to delete ice creams
         

Let's add our routes to router.js

import {

  createBrowserRouter,
  createRoutesFromElements,
  Route,
} from "react-router-dom"

import App from "./App"
import Index from "./pages/Index"
import Show from "./pages/Show"

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<App />}>
      <Route path="" element={<Index />} />
      <Route path=":id" element={<Show />} />
      <Route path="create" />
      <Route path="update/:id" />
      <Route path="delete/:id" />
    </Route>
  )
)

export default router        

In React, the <Outlet> component is used in conjunction with the <Route> component to render the appropriate component based on the current URL. It serves as a placeholder where the matched components will be rendered.

Add the following to App.js:

import { Outlet } from "react-router-dom"
import Header from "./components/Header"

function App() {
  return (
    <div className="App">
      <Header />
      <Outlet />
    </div>
  )
}

export default App        

Setting Up Navigation

In Header.js, let's include the following:

import { Link } from "react-router-dom"

function Header(props) {
  return (
    <nav className="nav">
      <Link to="/">
        <div>Ice Cream App</div>
      </Link>
    </nav>
  )
}

export default Header        

Inside the Header component, a nav element with a class of "nav" is created to wrap the navigation links. Within the nav element, a Link component is used to create a clickable link to the root path of the application ("/" in this case).

Sass Styling

Sass is a tool that enables developers to write CSS code in a more advanced way, introducing new features such as nested selectors, mixin, and variables. Let's add some basic Sass in our styles.scss!

// Define SASS variables
$primary-color: #4d4d4d;
$secondary-color: #fff;
$accent-color: #f00;
$gray-color: #888;

// --------------------------
// Header
// --------------------------

nav {
    background-color: $primary-color;
    color: $secondary-color;
    display: flex;
    justify-content: flex-start;
  
    a {
      color: $secondary-color;
      text-decoration: none;
      display: flex;
      align-items: center;
  
      div {
        margin: 10px;
        font-size: 24px;
        font-weight: bold;
      }
    }
  }        

Displaying Ice Creams in Index

First we will give the index route a loader to load all the ice creams who should be listed. This is how loaders.js should look like:

const URL = "http://localhost:4000"

export const iceCreamsLoader = async () => {
  const response = await fetch(URL + "/icecreams")
  const icecreams = await response.json()
  return icecreams
}        

Although you can substitute the URL with the deployed version of your API, you should be aware that if you're using the free tier, it could be frustratingly slow. That's why, when working on a hobby project, I prefer running the API server locally during development.

Then attach this loader to our route in router.js

import {
  createBrowserRouter,
  createRoutesFromElements,
  Route,
} from "react-router-dom"
import App from "./App"
import { iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<App />}>
      <Route path="" element={<Index />} loader={iceCreamsLoader}/>
      <Route path=":id" element={<Show />} />
      <Route path="create" />
      <Route path="update/:id" />
      <Route path="delete/:id" />
    </Route>
  )
)

export default router        

Next, let's display our ice creams in Index.js

import { Link, useLoaderData } from "react-router-dom"

function Index (props) {
  const icecreams = useLoaderData()

    return (
      <div className="icecream-list">
        {icecreams.map(icecream =>(
          <div key={icecream.id} className="icecream">
            <Link to={`/${icecream.id}`}>
              <h2>{icecream.name}</h2>
            </Link>
            <p>{icecream.description}</p>
            <img src={icecream.image} alt={icecream.name} />
          </div>
        ))}
      </div>
    )
  }
  
  export default Index        

At this point, you should be able to see ice creams data displayed on the index page. If you are having trouble fetching the data, make sure your server is running.

Let's incorporate additional styling to enhance the appearance of our Index page. Add the following to styles.scss:

// --------------------------
// Index
// --------------------------
/* Define a style for the ice cream list */
$margin-top: 25px;
$box-padding: 20px;
$border-radius: 10px;
$box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
$box-width: 300px;

.icecream-list {
    margin-top: $margin-top;
    display: flex;
    flex-wrap: wrap;
    justify-content: left;
    align-items: center;
    flex-direction: row;
  }
  
  /* Define a style for the individual ice cream items */
  .icecream {
    margin: $margin-top/2;
    padding: $box-padding;
    border-radius: $border-radius;
    box-shadow: $box-shadow;
    background-color: $secondary-color;
    max-width: $box-width;
    width: $box-width;
  }
  
  /* Define a style for the ice cream image */
  .icecream img {
    width: 100%;
    height: auto;
    border-radius: 5px;
    margin-bottom: 10px;
  }
  
  /* Define a style for the ice cream name */
  .icecream h2 {
    font-size: 24px;
    margin-bottom: 10px;
  }
  
  /* Define a style for the ice cream description */
  .icecream p {
    font-size: 16px;
    margin-bottom: 10px;
    color: $gray-color;
  }
  
  /* Define a style for the ice cream link */
  .icecream a {
    color: #555;
    text-decoration: none;
    transition: all 0.3s ease;
  }
  
  .icecream a:hover {
    color: $accent-color;
  }        
No alt text provided for this image

Creating Ice Creams

Up until this point, we have been using Postman to add ice creams. Let's switch things up and add a form to our Index.js file. To do this, we'll make use of the react-router form component which will trigger a route action upon submission. Our goal is to create an action that will take the form data and use it to create a new ice cream entry within our API.

Index.js

import { Form, Link, useLoaderData } from "react-router-dom"

function Index (props) {
  const icecreams = useLoaderData()

    return (
      <>
      <div className="create">
        <Form action="/create" method="post">
          <input type="input" name="name" placeholder="Ice Cream Name" />
          <input type="input" name="description" placeholder="Ice Cream Description" />
          <input type="input" name="image" placeholder="Ice Cream Image URL" />
          <input type="submit" value="Create Ice Cream" />
        </Form>
      </div>
      <div className="icecream-list">
        {icecreams.map(icecream =>(
          <div key={icecream.id} className="icecream">
            <Link to={`/${icecream.id}`}>
              <h2>{icecream.name}</h2>
            </Link>
            <p>{icecream.description}</p>
            <img src={icecream.image} alt={icecream.name} />
          </div>
        ))}
      </div>
      </>
    )
  }
  
  export default Index        

In React, the <> and </> allow you to group a list of children without adding extra nodes to the DOM. We use the <> and </> to wrap the entire component's JSX elements. This is because in JSX, a component must return a single parent element, and using the fragment syntax allows you to return multiple elements without needing to wrap them in a single parent element like a <div> or <span>.

Next, we should define an action to handle the submission of the form when the /create route is accessed. To do so, let's incorporate the following code into the actions.js file:

import { redirect } from "react-router-dom";

const URL = "http://localhost:4000"

export const createAction = async ({ request }) => {
    // get data from form
    const formData = await request.formData()

    // set up our new ice cream to match schema
    const newIceCream = {
        name: formData.get("name"),
        description: formData.get("description"),
        image: formData.get("image"),
    }

    // send new ice cream to API
    await fetch(URL + "/icecreams", {
        method: "post",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(newIceCream),
      })
      // redirect to index
      return redirect("/")

}        

Now let's attach this action to the right route in router.js:

import {
  createBrowserRouter,
  createRoutesFromElements,
  Route,
} from "react-router-dom"
import App from "./App"
import { iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
import { createAction } from "./actions"

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<App />}>
      <Route path="" element={<Index />} loader={iceCreamsLoader}/>
      <Route path=":id" element={<Show />} />
      <Route path="create" action={createAction}/>
      <Route path="update/:id" />
      <Route path="delete/:id" />
    </Route>
  )
)

export default router        

You should now be able to see all the ice creams and create ice creams! Let's add some styling to the form. add the following to styles.sass

// --------------------------
// Create Form
// --------------------------

  .create {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-top: 25px;
  }
  
  input {
    font-size: 1rem;
    padding: 0.5rem;
    margin: 0.5rem;
    border: none;
    border-radius: 0.25rem;
    box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
  }
  
  input[type="submit"] {
    background-color: $gray-color;
    color: $secondary-color;
    cursor: pointer;
  }
  
  input[type="submit"]:hover {
    background-color: $primary-color;
  }
  
  input:focus {
    outline: none;
    box-shadow: 0 0 5px rgba(255, 0, 0, 0.2);
  }        

The Show Page

Let's make the links clickable to take the user to the corresponding show page. We need to make some changes to enable this functionality.

To begin with, we need to add a loader for the show route, which we can do in the loaders.js file.

export const iceCreamLoader = async ({params}) => {
  const response = await fetch(URL + "/icecreams/" + params.id)
  const icecream = await response.json()
  return icecream
}        

Let's attach the loader to the route in router.js

import {
  createBrowserRouter,
  createRoutesFromElements,
  Route,
} from "react-router-dom"

import App from "./App"
import { iceCreamLoader, iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
import { createAction } from "./actions"

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<App />}>
      <Route path="" element={<Index />} loader={iceCreamsLoader}/>
      <Route path=":id" element={<Show />} loader={iceCreamLoader}/>
      <Route path="create" action={createAction}/>
      <Route path="update/:id" />
      <Route path="delete/:id" />
    </Route>
  )
)

export default router        

Next, let's build our Show component in Show.js

import { useLoaderData } from "react-router-dom"

function Show(props) {

  const iceCream = useLoaderData()
  console.log(`ice cream:`, iceCream)

    return (
      <div className="show">
        <h2>{iceCream.name}</h2>
        <p>{iceCream.description}</p>
        <img src={iceCream.image} alt={iceCream.name}/>
      </div>
    )
  }
  
  export default Show        

You have successfully built the basic structure of the Show component. Now is the perfect time to get creative and add your own styling to styles.scss to make your ice cream app look amazing! Experiment with different colors, fonts, and layouts to make your app stand out. Happy styling!

Updating Ice Cream

  • We'll add a router form to Show page.
  • A new action will be created to handle form submission to the /update/:id route.
  • We'll then attach this action to the appropriate route.

Show.js

import { Form, useLoaderData } from "react-router-dom"

function Show(props) {

  const iceCream = useLoaderData()

    return (
      <div className="show">
        <h2>{iceCream.name}</h2>
        <p>{iceCream.description}</p>
        <img src={iceCream.image} alt={iceCream.name} />

        <h2>Updtae {iceCream.name}</h2>
        <Form action={`/update/${iceCream._id}`} method="post">
          <input type="input" name="name" placeholder="Ice Cream Name" defaultValue={iceCream.name}/>
          <input type="input" name="description" placeholder="Ice Cream Description" defaultValue={iceCream.description}/>
          <input type="input" name="image" placeholder="Ice Cream Image URL" defaultValue={iceCream.image}/>
          <input type="submit" value={`update ${iceCream.name}`} />
        </Form>
      </div>
    )
  }
  
  export default Show        

Now let's add an action to handle the update to actions.js

export const updateAction = async ({request, params}) => {

  // get data from form
  const formData = await request.formData()

  // set up our new ice cream to match schema
  const updatedIceCream = {
      name: formData.get("name"),
      image: formData.get("image"),
      description: formData.get("description")
  }

  // Send new ice cream to our API
  await fetch(URL + "/icecreams/" + params.id, {
      method: "put",
      headers: {
          "Content-Type":"application/json"
      },
      body: JSON.stringify(updatedIceCream)
  })
  // redirect to index
  return redirect("/")
}
Lastly, add theupdateAction
updateAction        

Lastly, add updateAction to the create route in router.js and test your new form!

import {
  createBrowserRouter,
  createRoutesFromElements,
  Route,
} from "react-router-dom"

import App from "./App"
import { iceCreamLoader, iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
import { createAction, updateAction } from "./actions"

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<App />}>
      <Route path="" element={<Index />} loader={iceCreamsLoader}/>
      <Route path=":id" element={<Show />} loader={iceCreamLoader}/>
      <Route path="create" action={createAction}/>
      <Route path="update/:id" action={updateAction}/>
      <Route path="delete/:id" />
    </Route>
  )
)

export default router        

Delete Ice Cream

We can easily incorporate a delete button by using a Form and following a similar pattern.

  • Add Form
  • Add Action
  • Add action to route

Show.js

import { Form, useLoaderData } from "react-router-dom"

function Show(props) {

  const iceCream = useLoaderData()

    return (
      <div className="show">
        <h2>{iceCream.name}</h2>
        <p>{iceCream.description}</p>
        <img src={iceCream.image} alt={iceCream.name} />

        <h2>Updtae {iceCream.name}</h2>
        <Form action={`/update/${iceCream._id}`} method="post">
          <input type="input" name="name" placeholder="Ice Cream Name" defaultValue={iceCream.name}/>
          <input type="input" name="description" placeholder="Ice Cream Description" defaultValue={iceCream.description}/>
          <input type="input" name="image" placeholder="Ice Cream Image URL" defaultValue={iceCream.image}/>
          <input type="submit" value={`update ${iceCream.name}`} />
        </Form>

        <h2>Delete {iceCream.name}</h2>
        <Form action={`/delete/${iceCream._id}`} method="post">
        <input type="submit" value={`delete ${iceCream.name}`} />
        </Form>
      </div>
    )
  }
  
  export default Show        

Create deleteAction in action.js

export const deleteAction = async ({params}) => {
  // Send delete request to our API
  await fetch(URL + "/icecreams/" + params.id, {
    method: "delete"
  })
  // redirect to index
  return redirect("/")
}        

Attach the action deleteAction to our delete/:id route in router.js

import {
  createBrowserRouter,
  createRoutesFromElements,
  Route,
} from "react-router-dom"

import App from "./App"
import { iceCreamLoader, iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
import { createAction, deleteAction, updateAction } from "./actions"

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<App />}>
      <Route path="" element={<Index />} loader={iceCreamsLoader}/>
      <Route path=":id" element={<Show />} loader={iceCreamLoader}/>
      <Route path="create" action={createAction}/>
      <Route path="update/:id" action={updateAction}/>
      <Route path="delete/:id" action={deleteAction}/>
    </Route>
  )
)

export default router        

Congratulations! CRUD functionality should be complete!

Link to Project Repo

Deployment

Now that we are deploying the React app and aiming to make the data accessible from anywhere, it's advisable to switch from running your API locally to the deployed version. For sensitive or paid APIs, it's common practice to hide the link in an environment variable, so that the link is not exposed in the code. However, for an educational project like ours, this may not be necessary. You can simply update the URL to the deployed API link.

  • Sign up for a Render account if you haven't already. You can do this at https://render.com/.
  • Create a new static site by clicking the "New" button on your Render dashboard and selecting the type of static site.
  • Connect your code repository to Render by selecting the repository you want to use.
  • Click the "Create static site" button to deploy your app. Once the deployment process is complete, you can access your app by clicking the link provided on the Render dashboard.

I hope that this tutorial has been informative and helpful in guiding you through the process of building your own ice cream app from scratch. By combining the backend and frontend skills learned in both parts of this tutorial, you have all the tools necessary to create a fully functional web app that you can deploy and share with others. Remember to continue exploring and experimenting with different tools and technologies to enhance the functionality and user experience of your app. Happy coding and enjoy your sweet creations!

GitHub

Fantastic walk through Ayelet! Excited to see what is on the horizon for you and your upcoming content.

To view or add a comment, sign in

More articles by Ayelet Hillel

Others also viewed

Explore content categories