DEV Community

Rolando for AWS Community Builders

Posted on • Edited on

Create an online C.V with NextJs + AWS Amplify + Tailwind CSS

Source code can be found here

I decided to create all my new solo projects with AWSAmplify and NextJs, so far the most efficient way of building great full-stack serverless apps that I have come across. Tailwind CSS is also a nice piece of technology that I'm playing with, which fits great for this small example.

Environment & prerequisites

Before we begin, make sure you have the following:

  • Node.js v10.x or later installed
  • A valid and confirmed AWS account

Next Js

Let's set up our Next Js project by first running:

npx create-next-app online-dev-cv

Let's go to our new repo:

cd online-dev-cv

Tailwind CSS

The next thing is to install and configure Tailwind CSS inside our project.

Let's run:

npm install tailwindcss@latest postcss@latest autoprefixer@latest postcss-preset-env

After installing these two, let's do:

npx tailwindcss init

The npx tailwindcss init command will create a tailwind.config.js file, after this, we need to create another file called style.css

touch styles/styles.css

Moving further, add the following lines to the styles.css file.

 @tailwind base; 
 @tailwind components; 
 @tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

When the app builds, some CSS is generated with these lines of code, for example, the @tailwind base is the equivalent of a Normalize.css

Post CSS

We are almost done adding TailwindCSS, what’s left, is to create the postcss.config.js file, which stores the configuration for PostCSS.

Let's do:

touch postcss.config.js

Now let's add the following to the recently created file:

module.exports = {
   plugins: ["tailwindcss", "postcss-preset-env"],
};
Enter fullscreen mode Exit fullscreen mode

Finally, add import '../styles/styles.css' to pages/_app.js file. We can now use Tailwind everywhere in the application!

Installing and Initializing an AWS Amplify Project

Installing the CLI

AWS Amplify CL

NPM
$ npm install -g @aws-amplify/cli

cURL (Mac & Linux)
curl -sL https://aws-amplify.github.io/amplify-cli/install | >bash && $SHELL

cURL (Windows)
curl -sL https://aws-amplify.github.io/amplify-cli/install->win -o install.cmd && install.cmd

Let's now configure the CLI with our credentials.

If you'd like to see a walkthrough of this configuration process, Nader Dabit has a video that show's how here

Run: $ amplify configure

- Specify the AWS Region: us-east-1 || us-west-2 || eu-central-1
- Specify the username of the new IAM user: your-user-name
> In the AWS Console, click Next: Permissions, Next: Tags, Next: Review, & Create User to create the new IAM user. Then return to the command line & press Enter.
- Enter the access key of the newly created user:   
? accessKeyId: (<YOUR_ACCESS_KEY_ID>)  
? secretAccessKey: (<YOUR_SECRET_ACCESS_KEY>)
- Profile Name: your-user-name
Enter fullscreen mode Exit fullscreen mode

Let's initialise a new project by running: $ amplify init

? Enter a name for the project: onlinedevcv
? Enter a name for the environment: dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building: javascript
? What javascript framework are you using: react
? Source Directory Path:  .
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start
? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use: your-user-name
Enter fullscreen mode Exit fullscreen mode

Next, we need to define the schema models for our database, to do this, let's run:

$ amplify add api

? Please select from one of the below-mentioned services: GraphQL
? Provide API name: CV
? Choose the default authorization type for the API: API key
? Enter a description for the API key: public
? After how many days from now the API key should expire (1-365): 365
? Do you want to configure advanced settings for the GraphQL API: No, I am done.
? Do you have an annotated GraphQL schema?: No
? Choose a schema template: One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”)
? Do you want to edit the schema now? Yes
Enter fullscreen mode Exit fullscreen mode

We should now see the schema open in our text editor, which we are going to modify to look like this:

type MainDetails @model {
  id: ID!
  name: String!
  lastName: String!
  email: AWSEmail!
  location: String
  description: String
  skills: [Skill] @connection(keyName: "bySkill", fields: ["id"])
}

type Skill @model @key(name: "bySkill", fields: ["skillID"]) {
  id: ID!
  name: String!
  skillID: ID!
  mainDetails: MainDetails @connection(fields: ["skillID"])
}

Enter fullscreen mode Exit fullscreen mode

We now have two basic models related to each other, the next thing to do is to push these changes so AWS can know about it, for that, let's simply run:

$ amplify push

You will be asked some additional questions while running this process, answer to them with the following:

? Do you want to generate code for your newly created GraphQL API: Yes
? Choose the code generation language target: javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions: src/graphql/**/*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions: Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested]: 2

You should now have a graphql folder with the queries, mutations, subscriptions ( all with code examples for getting, updating, and deleting data ), and the schema files.

Let's add some details to our C.V directly through AWS AppSync by running:

$amplify console

? Which site do you want to open?  
  Amplify admin UI
❯ Amplify console
Enter fullscreen mode Exit fullscreen mode

Your AWS console page for your app should be open in the browser, click on the API tab, then on the right corner click the View in AppSync button:

Screen Shot 2021-01-25 at 23.27.00

On our left menu, let's click on the query link and add our following mutation:

mutation CreateMainDetails{
    createMainDetails(input: {
      name:"Joe",
      lastName: "Satriani",
      email:"jsatriani@email.com",
      location: "Lima",
      description: "I'm Jamstack developer",
    }) {
      id
      name
      lastName
      email
      location
      description
    }
}
Enter fullscreen mode Exit fullscreen mode

Screen Shot 2021-01-26 at 21.25.56

This should create for us, a new object with the person's main details, including a fresh id that we will be using for also adding skills to this person.

Save that id somewhere, let's now add a new skill by placing this mutation to our AWS AppSync console:

mutation CreateSkill {
  createSkill(input: { name: "AWS amplify", skillID:"your-main-skill-id" }) {
      id
      name
      skillID
      mainDetails {
        id
        name
        lastName
        description
        skills {
          items{
            id
            name
          }
        }
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Screen Shot 2021-01-26 at 21.39.00

A new skill has been added, we now have enough data to pull out our online C.V

Configuring Next Js with AWS amplify

Let's go to the pages/_app.js file, import the auto-generated aws-exports.js and add the Amplify.configure function with our config settings, the file should look like this:

import '../styles/globals.css'
import '../styles/styles.css'

import Amplify from 'aws-amplify';
import config from '../aws-exports';

Amplify.configure({
  ...config, ssr: true
});

function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />
}

export default MyApp
Enter fullscreen mode Exit fullscreen mode

We are now ready to code this full-stack application! Let's open our pages/index.js file and first import what we need:

import { useState, useEffect } from 'react';
import Link from 'next/link';
import { API } from 'aws-amplify';
import { listMainDetails } from '../graphql/queries'
Enter fullscreen mode Exit fullscreen mode

With the API function, we can call our Graphql query, to then pass it to a React state once it is resolved. We are also going to replace the default boilerplate created by NextJs with something simpler for now

export default function Home() {
  let [mainDetails, setListMainDetails] = useState(null);

  useEffect(() => {
    fetchListMainDetailss()
  },[])

  async function fetchListMainDetailss() {
    const mainDetails = await API.graphql({
      query: listMainDetails
    });
    const { data: { listMainDetailss } } = mainDetails;
    console.log(listMainDetailss.items[0])
    setListMainDetails(listMainDetailss.items[0]);
  }

  return (
    <div className="relative py-3 sm:max-w-xl sm:mx-auto">
      <Head>
        <title>My online C.V</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

We can create as many C.V as we want, but we are only going to need one, that's why we did listMainDetailss.items[0]

Next, let's start the app: $npm run dev

When opening http://localhost:3000/, we should not see anything on the screen, but if we open the console, our data should appear there:

{id: "c6db2857-36a5-4d3c-a96d-9e4dcf199c77d", name: "Joe", lastName: "Satriani", email: "jsatriani@email.com", location: "Lima", …}
Enter fullscreen mode Exit fullscreen mode

Let's add a loading state to not break the app while waiting for our data plus some JSX with Tailwind CSS classes, our final page should look like this:

import Head from 'next/head';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { API } from 'aws-amplify';
import { listMainDetails } from '../graphql/queries'

export default function Home() {
  let [mainDetails, setListMainDetails] = useState(null);
  let [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetchListMainDetailss()
  },[])

  async function fetchListMainDetailss() {
    const mainDetails = await API.graphql({
      query: listMainDetails
    });
    setIsLoading(false);
    setListMainDetails(listMainDetailss.items[0]);
  }
  if(isLoading || !mainDetails) {
    return <p>..Loading</p>
  }
  return (
    <div className="relative py-3 sm:max-w-xl sm:mx-auto">
      <Head>
        <title>My online C.V</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <div className="mt-5 md:mt-5 md:col-span-2">
        <div className="bg-white shadow overflow-hidden sm:rounded-lg">
          <div className="px-4 py-5 sm:px-6">
            <h3 className="text-lg leading-6 font-medium text-gray-900">
              My Online C.V
            </h3>
          </div>
          <div className="border-t border-gray-200">
            <dl>
              <div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                <dt className="text-sm font-medium text-gray-500">
                  Name
                </dt>
                <dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
                  {mainDetails.name}
                </dd>
              </div>
              <div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                <dt className="text-sm font-medium text-gray-500">
                  Last Name
                </dt>
                <dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
                  {mainDetails.lastName}
                </dd>
              </div>
              <div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                <dt className="text-sm font-medium text-gray-500">
                  Email address
                </dt>
                <dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
                  {mainDetails.email}
                </dd>
              </div>
              <div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                <dt className="text-sm font-medium text-gray-500">
                  Location
                </dt>
                <dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
                  {mainDetails.location}
                </dd>
              </div>
              <div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                <dt className="text-sm font-medium text-gray-500">
                  Description
                </dt>
                <dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
                  {mainDetails.description}
                </dd>
               </div>
               <div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                <Link href={`/edit/${mainDetails.id}`}>
                  <div className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                    Edit
                  </div>
                </Link>
              </div>
            </dl>
          </div>
        </div>
      </div>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Notice we created an edit "button" with a href tag href={'/edit/${mainDetails.id}'}, that means we will need a new page where will be updating our C.V based on the mainDetails id.

Let's create a new folder inside our page directory called edit, then we need a new file name [id].js inside

Open the new page (page/[id].js) copy and paste the following code:

import { useState, useEffect } from 'react';
import { API } from 'aws-amplify';
import { useRouter } from 'next/router';
import { getMainDetails, listMainDetails  } from '../../graphql/queries';
import { updateMainDetails } from '../../graphql/mutations';

const UpdateDetails = ({ mainDetails }) => {
  const router = useRouter();
  if (router.isFallback || !mainDetails) {
    return <div>Loading...</div>
  }
  const [onChangeDetail, setOnChangeDetail] = useState({ 
    name: '',  
    lastName: '' , 
    email: '', 
    location: '',
    description: '',
    skills: { items: []},
  });

  useEffect(() => {
    setOnChangeDetail(mainDetails);
  },[]);

  const handleInpuChange = (event,keyName)  =>  {
    event.persist();
    setOnChangeDetail( (onChangeDetail)  =>  {
      return  { ...onChangeDetail,  [ keyName ] : event.target.value }
    });
  }

  const updateDetails = async () => {
    delete onChangeDetail.skills
    await API.graphql({
      query: updateMainDetails,
      variables: { input: onChangeDetail },
    })
    router.push(`/`);
  }

  return (
    <>
      <div className="shadow sm:rounded-md sm:overflow-hidden relative py-3 sm:max-w-xl sm:mx-auto mt-4">
        <div className="px-4 py-5 bg-white space-y-6 sm:p-6">
          <h2 className="text-lg">Main Details</h2>
          <div>
            <label htmlFor="name" className="block text-sm font-medium text-gray-700">
              Name
            </label>
            <div className="mt-1">
              <input 
                type="text" 
                name="name"
                value={onChangeDetail.name}
                onChange = { (event) => handleInpuChange(event ,'name') }
                className="focus:ring-indigo-500 focus:border-indigo-500 flex-1 block w-full rounded-md sm:text-sm border-gray-300" 
              />
            </div>
          </div>

          <div>
            <label htmlFor="last name" className="block text-sm font-medium text-gray-700">
              Last name
            </label>
            <div className="mt-1">
              <input 
                type="text" 
                name="last name" 
                value={onChangeDetail.lastName}
                onChange = { (event) => handleInpuChange(event ,'lastName') }
                className="focus:ring-indigo-500 focus:border-indigo-500 flex-1 block w-full rounded-md sm:text-sm border-gray-300" 
              />
            </div>
          </div>

          <div>
            <label htmlFor="email" className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <div className="mt-1">
              <input 
                type="email" 
                name="email" 
                value={onChangeDetail.email}
                onChange = { (event) => handleInpuChange(event ,'email') }
                className="focus:ring-indigo-500 focus:border-indigo-500 flex-1 block w-full rounded-md sm:text-sm border-gray-300" 
              />
            </div>
          </div>

          <div>
            <label htmlFor="location" className="block text-sm font-medium text-gray-700">
              Location
            </label>
            <div className="mt-1">
              <input 
                type="text" 
                name="location"
                value={onChangeDetail.location}
                onChange = { (event) => handleInpuChange(event ,'location') }
                className="focus:ring-indigo-500 focus:border-indigo-500 flex-1 block w-full rounded-md sm:text-sm border-gray-300" 
              />
            </div>
          </div>

          <div>
            <label htmlFor="description" className="block text-sm font-medium text-gray-700">
              Description
            </label>
            <div className="mt-1">
              <textarea 
                name="description" 
                rows="3" 
                value={onChangeDetail.description}
                onChange = { (event) => handleInpuChange(event ,'description') }
                className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" 
                placeholder="Some words about yourself"
              />
            </div>
          </div>
        </div>
      </div>
      <div className="text-center mt-1 px-4 py-5 space-y-6 sm:p-6">
        <button 
          type="submit"
          onClick={updateDetails}
          className="inline-flex justify-center py-2 px-20 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
        >
          Update C.V
        </button>
      </div>
    </>
  )
}

export async function getStaticPaths() {
  const postMainDetails = await API.graphql({
    query: listMainDetails,
  });

  const paths = postMainDetails.data.listMainDetailss.items.map(post => ({ params: { id: post.id }}))
  return {
    paths,
    fallback: true,
  }
}

export async function getStaticProps({ params }) {
  const { id } = params
  let mainDetails = await API.graphql({
    query: getMainDetails,
    variables: { id }
  });
  return {
    props: {
      mainDetails: mainDetails.data.getMainDetails
    }
  }
}

export default UpdateDetails
Enter fullscreen mode Exit fullscreen mode

If you are not familiar with Next js notice there is a couple of interesting functions at the end of the page.

getStaticProps is for enabling the MainDetails data into the page as props at build time.

getStaticPaths is for creating dynamically pages at build time based on the MainDetails coming back from the API

The rest is the normal React code seen in many apps plus our corresponding query and mutation to GET and POST data ( make sure the updateDetails mutation includes the skills{ items } object)

We have not forgotten about our skills data yet, we will take care of this one in a separate component, outside our pages folder let's create a new file components/skillsUpdate and paste the following code:

import { useState, useEffect } from 'react';
import { API, graphqlOperation } from 'aws-amplify';
import { createSkill } from '../graphql/mutations';
import { onCreateSkill } from '../graphql/subscriptions';
import { v4 as uuid } from 'uuid';

const SkillsUpdate = ({ skills, id }) => {
  const [onChangeSkill, setOnChangeSkill] = useState('');
  const [newSkills, setNewSkills] = useState([]);

  useEffect(() => {
    setNewSkills(skills)
  },[])

  useEffect(() => {
    const subscription = API.graphql(graphqlOperation(onCreateSkill)).subscribe({
      next: (eventData) => {
        const newSkill = eventData.value.data.onCreateSkill;
        const id = uuid()

        setNewSkills([...skills, { id, name: newSkill.name}])
      },
      error: error => console.error(error)
    })
    return () => subscription.unsubscribe()
  }, [])

  const addSkill = async() => {
    await API.graphql({
      query: createSkill,
      variables: { 
        input: { skillID: id, name: onChangeSkill }
      }
    }); 
    setOnChangeSkill('');
  }
  return (
    <div className="shadow sm:rounded-md sm:overflow-hidden relative py-3 sm:max-w-xl sm:mx-auto mt-4">
      <div className="mt-1 px-4 py-5 space-y-6 sm:p-6">
        <h2 className="text-lg">Skills</h2>
        <input 
          type="text" 
          name="skill"
          value={onChangeSkill}
          onChange = { (event) => setOnChangeSkill(event.target.value) }
          className="focus:ring-indigo-500 focus:border-indigo-500 flex-1 block w-full rounded-md sm:text-sm border-gray-300" 
        />
      <div className="flex flex-wrap -m-2 mb-4">
        {newSkills.map(skill => (
          <div
            key={skill.id}
            className="bg-green-400 px-10 py-2 text-white text-center rounded-full mb-3 mr-2"
          >
            {skill.name}
          </div>
          ))
        }
      </div>
      <div>
        <button 
          type="submit"
          onClick={addSkill}
          className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
          >
          Add skill
        </button>
        </div>
      </div>
    </div>
  )
}

export default SkillsUpdate;
Enter fullscreen mode Exit fullscreen mode

Here we are tackling 3 things:

  • Rendering the skills data through props coming from our [id].js page

  • Adding skills to our data, thanks to our Graphql mutation

  • Managing subscription so we can have real updates every time we add a new skill ( make sure the onCreateSkill subscription includes the skills{ items } object)

Finally, we need to import and place our SkillsUpdate component into our [id].js page (about the update button) to get all the pieces together

.....
import SkillsUpdate from '../../components/skillsUpdate';
Enter fullscreen mode Exit fullscreen mode

and

.....
     <SkillsUpdate 
        skills={mainDetails.skills.items}
        id={mainDetails.id} 
      />
      <div className="text-center mt-1 px-4 py-5 space-y-6 sm:p-6">
        <button 
          type="submit"
          onClick={updateDetails}
          className="inline-flex justify-center py-2 px-20 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
        >
          Update C.V
        </button>
      </div>
Enter fullscreen mode Exit fullscreen mode

Deployment

Create a new file, name it serverless.yml, and place the following code :

nextamplified:
  component: "@sls-next/serverless-component@1.18.0"
Enter fullscreen mode Exit fullscreen mode

Run npx serverless

After a few seconds of creating all the necessary files, you should see a message with your deployed URL

You now have two new big folders created /.serverless and /.serverless_nextjs, they both can go to the .gitignore file

Done, if you have any questions, feel free to ask in the comment box.

If you think other people should read this post. Tweet, share and follow me on twitter

Top comments (2)

Collapse
 
hrqmonteiro profile image
Henrique Monteiro • Edited

I am trying to do this for, like, four hours now.

And it ALWAYS give me the same error:

Unhandled Runtime Error
TypeError: Cannot read property 'items' of undefined
Enter fullscreen mode Exit fullscreen mode
pages/index.js (20:57) @ _callee$

  18 |   });
  19 |   setIsLoading(false);
> 20 |   setListMainDetails(mainDetails.data.listMainDetailss.items[0]);
     |                                                       ^
  21 | }
  22 | if(isLoading || !mainDetails) {
  23 |   return <p>..Loading</p>
Enter fullscreen mode Exit fullscreen mode

img

I tried building it from scratch and i got this error. I tried cloning your repo and just authenticating with Amplify and i got this same error.
Something is wrong on your code and explanation.

Collapse
 
jaymitb profile image
Jaymitkumar Bhoraniya

Really awesome, thank you for sharing.