Build a SPA with AWS DataStore and Next

AWS Amplify provides developers services for building their dream app. An opportunity as presented in the Hashnode and AWS Amplify hackathon gives me the leverage to explore some of the tools to expand my horizon to try out a new product.

This article highlight the possibility of using AWS DataStore to create a backend environment for your application and creating models that stores various data entities that can be pulled into your frontend application of choice like Next.js

Next.js is a React library for building user interfaces and reusable components for your front-end web application.

Amplify DataStore provides a way to work with both local and remote data to build fullstack applications, which don’t need background knowledge in GraphQL to work with it.

The inspiration for this article is to create database storage for information that can be created and updated from the store to build an app.

Build Your Dream Project with AWS Amplify on September's Hackathon 🧡

Pre-requisites

The following are required for setup as a guide for this tutorial:

  • Node >= 14.0.0 and npm >= 5.6 installed on our local machine for package installation
  • Have an AWS account. Register for an account here
  • Knowledge of JavaScript and React
  • A code editor
  • Globally install the AWS CLI with this command in your terminal
npm install -g @aws-amplify/cli

If this is your first time running this command, a prompt will appear, leading you to authenticate your account.

Code

The source code is in this GitHub repository. Fork and run it in your local environment.

Deployed Site

Check out the live demo and see how it functions.

Creating a New Project in Amplify

As stated in the pre-requisites section, sign in to your newly created AWS account after signing up. Search for AWS Amplify in the search bar for services.

User-uploaded image: image.png

Select AWS Amplify from the list of services. In the All apps dashboard, select the New app button and from the dropdown, select Build an app.

Next, give your app a name and confirm the deployment of the app.

As the app is deploying, let’s create a Next.js project.

Creating an App with Next

In your terminal, enter the following command to scaffold a new project with create-next-app.

npx create-next-app next-datastore

The name of the project next-datastore, can be renamed to any name you so desire to use.

Launch Amplify Studio

After installing the Next project, head back to your Amplify console to start the app launch in the backend environments tab and click the launch studio button.

The other part of this process is to a create data model.

For that, you will create an entity called Recipe that will store and represent all the data for your application. On the left navigation bar under set up, select Data and create a model.

For the menu-app project, the following data will be used to render the menus on the client-side:

  • title
  • ingredients
  • cookingTime
  • method
  • featuredImage

The id field name will be uniquely generated, so you don’t need to declare it. Save and deploy the data model, which will take some time to finish.

Behind the scenes, many things happen when you press Deploy. For every model created (Recipe), Amplify created a DynamoDB table to serve as a database for your data.

Pull the Backend in Your App

After deployment, Amplify provides a command that should be used in your Next app. Open your terminal and paste the command.

amplify pull --appId <YOUR_APP_ID> --envName staging

The <YOUR_APP_ID> is different for every application.

The above command displays a series of prompts to choose from your app.

The command, as shown in the screenshot above, pulls all the code and creates new folders that contain the backend and the data models.

Create Data in the Data Model

The data that will be queried later in your application must be created and made available for use.

In the navigation bar, under the Manage tab, select Content to begin populating data in the table. It is also possible to auto-generate data for your app by clicking the Actions dropdown menu.

Click Create recipe to fill in your recipe data.

Installing Dependencies

The following dependencies, Tailwind CSS and Heroicons are required to beautify and add icons to the application:

Check out this installation guide on Tailwind CSS in a Next.js application.

Next, install the app icons for your application using this command:

npm install @heroicons/react@v1

One more dependency required for the proper functioning of your application is the aws-amplify package for connecting the app with Amplify.

Run the command:

npm install aws-amplify

Dynamically Add Meta tags

In your Next application, create a new folder called components and create a file called Layout.jsx. This file will be responsible for defining the values of the header elements through a meta object and it will be applied to every application.

Copy and paste the following code.

// components/Layout.jsx

import Head from 'next/head';

export default function Layout({ children, pageMeta }) {
  const meta = {
    title: 'Recipes, Delicious dishes',
    description: 'Food that bring people together',
    type: 'website',
    ...pageMeta,
  };

  return (
    <>
      <Head>
        <title>{meta.title}</title>
        <meta name='description' content={meta.description} />
      </Head>
      <div>
        <main>{children}</main>
      </div>
    </>
  );
}

The code snippet above does the following:

  • Import the Head components which is the same as the <head> element in HTML.
  • Define the parameters children, and the pageMeta props which can be passed from any component of the app and this will make individual have their meta tags specific to the page
  • The meta object with the title and description values for the head element
  • The Head component accept the values from the meta object
  • Finally, the children props is wrapped around the main element

But first, create the files, Header.jsx , Footer.jsx, and Logo.jsx in the components folder

Copy and paste the following code:

// components/Logo.jsx

import Link from 'next/link';
import { CakeIcon } from '@heroicons/react/solid';

const Logo = () => (
  <Link href='/'>
    <a className='flex items-center space-x-1 text-red-600'>
      <CakeIcon className='w-8 h-8 flex-shrink-0' />
      <span className='font-bold text-lg tracking-light whitespace-nowrap'>
        Eat & Nourish
      </span>
    </a>
  </Link>
);

export default Logo;

In the Logo component, imports the Link component from Next and an icon from HeroIcons. The rest of the page are the classes from Tailwind CSS.

Next, copy and paste the following code to the Header component:

// components/Header.jsx

import Logo from './Logo';

const Header = () => {
  return (
    <header className='border-b border-gray-100'>
      <div className='max-w-7xl mx-auto w-11/12 flex justify-between items-center py-4'>
        <Logo />
      </div>
    </header>
  );
};

export default Header;

The Header component represent the navigation menu for the app and in this component, we import the Logo component.

The last component, the Footer component shows info about the website. Copy and paste the following code:

// components/Footer.jsx

const Footer = () => {
  const year = new Date().getFullYear();

  return (
    <footer className='bg-gray-900 text-gray-500'>
      <div className='flex flex-col justify-center items-center py-5'>
        <address>
          <span>
            <a href='https://twitter.com/terieyenike'>Teri </a>
          </span>
          &copy; {new Date().getFullYear()}
        </address>
        <div>
          <span>AWS Amplify x Hashnode hackathon.</span>
        </div>
      </div>
    </footer>
  );
};

export default Footer;

In the Footer component, declare the year variable with the JavaScript Date()method to dynamically render the year.

Now, let’s update the Layout.jsx file to include the Header and Footer components:

// components/Layout.jsx

import Header from './Header'; // add this
import Footer from './Footer'; // add this
...

export default function Layout({ children, pageMeta }) {
  ...

  return (
    <>
      {/* Head component with meta tags */}

      <div>
        <Header />
        <main>{children}</main>
        <Footer />
      </div>
    </>
  );
}

Before viewing how the app looks like, delete everything in the pages/index.js file and import the Layout component with this code:

// pages/index.js

import Layout from '../components/Layout';

export default function Home() {
  return (
    <Layout>
    </Layout>
  );
}

Start the development server to preview your application with this command:

npm run dev

The development server runs on http://localhost:3000 which has a hot-reload feature and update automatically on each save.

The menu app should look like this:

Creating the Menu App with Amplify

The menu app don’t have any data displayed on the client side from the backend DataStore in Amplify. In this section, you will learn how to render the images and some other data from the store and beautify the page to be aesthetically pleasing to users with Tailwind CSS.

Update the pages/index.js file with the following code:

// pages/index.js

import Layout from '../components/Layout';
import { DataStore } from 'aws-amplify';
import { serializeModel } from '@aws-amplify/datastore/ssr';
import { withSSRContext } from 'aws-amplify';
import { Recipe } from '../src/models';
import Image from 'next/future/image';
import Link from 'next/link';

export async function getServerSideProps({ req }) {
  const SSR = withSSRContext({ req });
  const recipes = await SSR.DataStore.query(Recipe);
  return {
    props: {
      recipes: serializeModel(recipes),
    },
  };
}

export default function Home({ recipes }) {
  function timeConvert(cookingTime) {
    var num = cookingTime;
    var hours = num / 60;
    var rhours = Math.floor(hours);
    var minutes = (hours - rhours) * 60;
    var rminutes = Math.round(minutes);
    const timeInHour = rhours > 1 ? 'hours' : 'hour';
    const timeInMinute = rminutes > 1 ? 'mins' : 'minute';
    return `${rhours} ${timeInHour} ${rminutes} ${timeInMinute}`;
  }

  return (
    <Layout>
      <div className='container mx-auto grid lg:grid-cols-3 md:grid-cols-2 xl:grid-cols-2 gap-8 py-10 w-11/12 max-w-7xl'>
        {recipes.map(({ id, title, featuredImage, cookingTime }) => (
          <div key={id}>
            <Image
              src={featuredImage}
              alt={title}
              width={500}
              height={500}
              className='w-full'
              priority
            />
            <div className='bg-white flex flex-col shadow-md p-5 -translate-y-6'>
              <div>
                <h4 className='font-bold'>{title}</h4>
                <p className='mb-5'>
                  Takes approx. {timeConvert(cookingTime)} to prepare
                </p>
              </div>
              <div className='self-end'>
                <Link href='/recipe/[id]' as={`/recipe/${id}`} passHref>
                  <a className='bg-red-600 text-white font-bold py-3 px-4'>
                    Cook this
                  </a>
                </Link>
              </div>
            </div>
          </div>
        ))}
      </div>
    </Layout>
  );
}

The code above does the following:

  • The DataStore API import is useful for quering the data
  • To convert the app data into a JSON, import the serializeModel helper function
  • The import withSSRContext creates an instance of Amplify scoped to a single request
  • The Recipe model imports all the data from the data model
  • The image and link components come from Next
  • To pre-render the pages, getServerSideProps helps with each request from the data returned
  • Within the Home component, pass in the recipes props from the getServerSideProps() to have access to the data from the model, Recipe
  • Using the map method, we get to loop through the objects and return the values to the page

To calculate the time, it takes to cook a meal, the function timeConvert calculates the approximate time for this action.

Also, to dynamically link to the various pages, we used the Link component to navigate to an individual using the dynamic route, /recipe/[id].js.

If you run into errors that the image fails to load, it is due to host images not being configured. To resolve this, open your next.config.js file and include the host image provided from the error page.

Individual Pages in Next

Still in the pages folder, create a new folder called recipe. And in there, create a file, [id].js that represent the individual page in the nenu app.

Copy and paste the following code:

// pages/recipe/[id].js

import Image from 'next/future/image';
import { ChevronDoubleLeftIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import Layout from '../../components/Layout';
import { DataStore } from 'aws-amplify';
import { withSSRContext } from 'aws-amplify';
import { serializeModel } from '@aws-amplify/datastore/ssr';
import { Recipe } from '../../src/models';

export async function getServerSideProps({ params }) {
  const SSR = withSSRContext();
  const data = await SSR.DataStore.query(Recipe, params.id);
  return {
    props: {
      recipe: serializeModel(data),
    },
  };
}

export default function Menu({ recipe }) {
  return (
    <Layout>
      <div className='max-w-7xl mx-auto w-11/12 py-10'>
        <Link href='/'>
          <a>
            <ChevronDoubleLeftIcon className='w-8 h-8 flex-shrink-0 mb-4' />
          </a>
        </Link>
        <Image
          src={recipe.featuredImage}
          alt='menu detail'
          width={500}
          height={500}
          className='md:w-full lg:w-2/4'
        />
        <h2 className='mt-2 mb-4'>{recipe.title.toUpperCase()}</h2>
        <div>
          <span className='font-bold uppercase'>Description</span>
          <p className='mb-6 max-w-6xl'>{recipe.description}</p>
          <span className='font-bold uppercase'>Ingredients</span>
          <p className='mb-6 max-w-6xl'>{recipe.ingredients}</p>
          <span className='font-bold uppercase'>Method</span>
          <p className='max-w-6xl'>{recipe.method}</p>
        </div>
      </div>
    </Layout>
  );
}

The same process applies to the previous Home page component, index.js. But this time, we are not looping through the objects, instead we get the individual value from the object and pass the required data. Also, Tailwind CSS classes provides styling for the app and the heroicons has an icon to navigate back to the home page.

Deploying the App

For an in-depth guide to deploy an application to Amplify, check out this guide to deploy modern apps.

Our app should now look something like this:

Conclusion

Learning to build with AWS gives software engineers the power to use a service that is reliable and efficient, making it good for building frontend and backend applications.

This article taught us to use the C in the CRUD operation by creating data for the backend of our application which proved to be useful by fetching the data in the client-side.

Try out Amplify today to build your dream project.

Learn More