Giter VIP home page Giter VIP logo

next.js-amplify-workshop's Introduction

Full Stack Cloud with Next.js, Tailwind, and AWS

Next.js Amplify Workshop

In this workshop we'll learn how to build a full stack cloud application with Next.js, Tailwind, & AWS Amplify.

What you'll be building.

App preview

App preview

App preview

App preview

Overview

We'll start from scratch, creating a new Next.js app. We'll then, step by step, use the Amplify CLI to build out and configure our cloud infrastructure and then use the Amplify JS Libraries to connect the Next.js app to the APIs we create using the CLI.

The app will be a multi-user blogging platform with a markdown editor. When you think of many types of applications like Instagram, Twitter, or Facebook, they consist of a list of items and often the ability to drill down into a single item view. The app we will be building will be very similar to this, displaying a list of posts with data like the title, content, and author of the post.

This workshop should take you anywhere between 1 to 4 hours to complete.

TOC

Environment & prerequisites

Before we begin, make sure you have the following:

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

We will be working from a terminal using a Bash shell to run Amplify CLI commands to provision infrastructure and also to run a local version of the Next.js app and test it in a web browser.

Background needed / level

This workshop is intended for intermediate to advanced front end & back end developers wanting to learn more about full stack serverless development.

While some level of React and GraphQL is helpful, this workshop requires zero previous knowledge about React or GraphQL.

Topics we'll be covering:

  • GraphQL API with AWS AppSync
  • Authentication
  • Authorization
  • Hosting
  • Deleting the resources

Getting Started - Creating the Next.js Application

To get started, we first need to create a new Next.js project.

$ npx create-next-app amplify-next

Now change into the new app directory & install AWS Amplify, AWS Amplify UI React and a few other libraries we'll be using:

$ cd amplify-next
$ npm install aws-amplify @aws-amplify/ui-react [email protected] react-markdown uuid

Since we will be using Tailwind, let's also install the tailwind dependencies:

npm install tailwindcss@latest postcss@latest autoprefixer@latest @tailwindcss/typography

Next, create the necessary Tailwind configuration files:

npx tailwindcss init -p

Now update tailwind.config.js to add the Tailwind typography plugin to the array of plugins:

plugins: [
  require('@tailwindcss/typography')
],

Finally, replace the styles in styles/globals.css with the following:

@tailwind base;
@tailwind components;
@tailwind utilities;

Installing the CLI & Initializing a new AWS Amplify Project

Installing the CLI

Next, we'll install the AWS Amplify CLI:

# 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

Now we need to configure the CLI with our credentials.

If you'd like to see a video walkthrough of this configuration process, click here.

$ amplify configure

- Specify the AWS Region: us-east-1 || us-west-2 || eu-central-1
- Specify the username of the new IAM user: amplify-cli-user
> 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: amplify-cli-user

Initializing A New Project

$ amplify init

- Enter a name for the project: amplifynext
- Initialize the project with the above configuration? No
- Enter a name for the environment: dev
- Choose your default editor: Visual Studio Code (or your default editor)
- Please choose the type of app that youre building: javascript
- What javascript framework are you using: react
- Source Directory Path: . (this sets the base directory to the root directory)
- Distribution Directory Path: .next
- Build Command: npm run-script build
- Start Command: npm run-script start
- Select the authentication method you want to use: AWS profile
- Please choose the profile you want to use: amplify-cli-user (or your preferred profile)

The Amplify CLI has initialized a new project & you will see a new folder: amplify & a new file called aws-exports.js in the root directory. These files hold your project configuration.

To view the status of the amplify project at any time, you can run the Amplify status command:

$ amplify status

To view the amplify project in the Amplify console at any time, run the console command:

$ amplify console

Adding an AWS AppSync GraphQL API

To add a GraphQL API, we can use the following command:

$ amplify add api

? Please select from one of the above mentioned services: GraphQL
? Provide API name: NextBlog
? 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 (or your preferred expiration)
? Do you want to configure advanced settings for the GraphQL API: No
? Do you have an annotated GraphQL schema? N 
? Choose a schema template: Single object with fields
? Do you want to edit the schema now? (Y/n) Y

The CLI should open this GraphQL schema in your text editor.

amplify/backend/api/NextBlog/schema.graphql

Update the schema to the following:

type Post @model {
  id: ID!
  title: String!
  content: String!
}

After saving the schema, go back to the CLI and press enter.

Deploying the API

To deploy the API, run the push command:

$ amplify push

? Are you sure you want to continue? Y

# You will be walked through the following questions for GraphQL code generation
? Do you want to generate code for your newly created GraphQL API? Y
? Choose the code generation language target: javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions: ./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

Now the API is live and you can start interacting with it!

Testing the API

To test it out we can use the GraphiQL editor in the AppSync dashboard. To open the AppSync dashboard, run the following command:

$ amplify console api

> Choose GraphQL

In the AppSync dashboard, click on Queries to open the GraphiQL editor. In the editor, create a new post with the following mutation:

mutation createPost {
  createPost(input: {
    title: "My first post"
    content: "Hello world!"
  }) {
    id
    title
    content
  }
}

Then, query for the posts:

query listPosts {
  listPosts {
    items {
      id
      title
      content
    }
  }
}

Configuring the Next app

Now, our API is created & we can test it out in our app!

The first thing we need to do is to configure our Next.js app to be aware of our Amplify project. We can do this by referencing the auto-generated aws-exports.js file that was created by the CLI.

Create a new file called configureAmplify.js in the root of the project and add the following code:

import Amplify from 'aws-amplify'
import config from './aws-exports'
Amplify.configure(config)

Next, open pages/_app.js and import the Amplify configuration below the last import:

import '../configureAmplify'

Now, our app is ready to start using our AWS services.

Interacting with the GraphQL API from the Next.js application - Querying for data

Now that the GraphQL API is running we can begin interacting with it. The first thing we'll do is perform a query to fetch data from our API.

To do so, we need to define the query, execute the query, store the data in our state, then list the items in our UI.

The main thing to notice in this component is the API call. Take a look at this piece of code:

/* Call API.graphql, passing in the query that we'd like to execute. */
const postData = await API.graphql({ query: listPosts })

Open pages/index.js and add the following code:

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

export default function Home() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetchPosts()
  }, [])
  async function fetchPosts() {
    const postData = await API.graphql({
      query: listPosts
    })
    setPosts(postData.data.listPosts.items)
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">Posts</h1>
      {
        posts.map((post, index) => (
        <Link key={index} href={`/posts/${post.id}`}>
          <div className="cursor-pointer border-b border-gray-300	mt-8 pb-4">
            <h2 className="text-xl font-semibold">{post.title}</h2>
          </div>
        </Link>)
        )
      }
    </div>
  )
}

Next, start the app:

$ npm run dev

You should be able to view the list of posts. You will not yet be able to click on a post to navigate to the detail view, that is coming up later.

Adding authentication

Next, let's add some authentication.

To add the authentication service, run the following command using the Amplify CLI:

$ amplify add auth

? Do you want to use default authentication and security configuration? Default configuration 
? How do you want users to be able to sign in when using your Cognito User Pool? Username
? Do you want to configure advanced settings? No, I am done. 

To deploy the authentication service, you can run the push command:

$ amplify push

? Are you sure you want to continue? Yes

Next, let's add a profile screen and login flow to the app.

To do so, create a new file called profile.js in the pages directory. Here, add the following code:

import { withAuthenticator, AmplifySignOut } from '@aws-amplify/ui-react'
import { Auth } from 'aws-amplify'
import { useState, useEffect } from 'react'

function Profile() {
  const [user, setUser] = useState(null)
  useEffect(() => {
    checkUser()
  }, [])
  async function checkUser() {
    const user = await Auth.currentAuthenticatedUser()
    setUser(user)
  }
  if (!user) return null
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6">Profile</h1>
      <h3 className="font-medium text-gray-500 my-2">Username: {user.username}</h3>
      <p className="text-sm text-gray-500 mb-6">Email: {user.attributes.email}</p>
      <AmplifySignOut />
    </div>
  )
}

export default withAuthenticator(Profile)

The withAuthenticator Amplify UI component will scaffold out an entire authentication flow to allow users to sign up and sign in.

The AmplifySignOut button adds a pre-style sign out button.

Next, add some styling to the UI component by opening styles/globals.css and adding the following code:

:root {
  --amplify-primary-color: #2563EB;
  --amplify-primary-tint: #2563EB;
  --amplify-primary-shade: #2563EB;
}

Next, open pages/_app.js to add some navigation and styling to be able to navigate to the new Profile page:

import '../styles/globals.css'
import '../configureAmplify'
import Link from 'next/link'

function MyApp({ Component, pageProps }) {
  return (
  <div>
    <nav className="p-6 border-b border-gray-300">
      <Link href="/">
        <span className="mr-6 cursor-pointer">Home</span>
      </Link>
      <Link href="/create-post">
        <span className="mr-6 cursor-pointer">Create Post</span>
      </Link>
      <Link href="/profile">
        <span className="mr-6 cursor-pointer">Profile</span>
      </Link>
    </nav>
    <div className="py-8 px-16">
      <Component {...pageProps} />
    </div>
  </div>
  )
}

export default MyApp

Next, run the app:

$ npm run dev

You should now be able to sign up and view your profile.

The link to /create-post will not yet work as we have not yet created this page.

Adding authorization

Next, update the API to enable another authorization type to enable both public and private API access.

$ amplify update api

? Please select from one of the below mentioned services: GraphQL   
? Select from the options below: Update auth settings
? 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 <or your preferred expiration>
? Configure additional auth types? Y
? Choose the additional authorization types you want to configure for the API: Amazon Cognito User Pool

Updating the API

Next, let's update the GraphQL schema with the following changes:

  1. A new field (username) to identify the author of a post.
  2. An @key directive for enabling a new data access pattern to query posts by username

Open amplify/backend/api/NextBlog/schema.graphql and update it with the following:

type Post @model
  @key(name: "postsByUsername", fields: ["username"], queryField: "postsByUsername")
  @auth(rules: [
    { allow: owner, ownerField: "username" },
    { allow: public, operations: [read] }
  ]) {
  id: ID!
  title: String!
  content: String!
  username: String
}

Next, deploy the updates:

$ amplify push --y

Now, you will have two types of API access:

  1. Private (Cognito) - to create a post, a user must be signed in. Once they have created a post, they can update and delete their own post. They can also read all posts.
  2. Public (API key) - Any user, regardless if they are signed in, can query for posts or a single post. Using this combination, you can easily query for just a single user's posts or for all posts.

To make this secondary private API call from the client, the authorization type needs to be specified in the query or mutation:

const postData = await API.graphql({
  mutation: createPost,
  authMode: 'AMAZON_COGNITO_USER_POOLS',
  variables: {
    input: postInfo
  }
})

Adding the Create Post form and page

Next, create a new page at pages/create-post.js and add the following code:

import { withAuthenticator } from '@aws-amplify/ui-react'
import { useState } from 'react'
import { API } from 'aws-amplify'
import { v4 as uuid } from 'uuid'
import { useRouter } from 'next/router'
import SimpleMDE from "react-simplemde-editor"
import "easymde/dist/easymde.min.css"
import { createPost } from '../graphql/mutations'

const initialState = { title: '', content: '' }

function CreatePost() {
  const [post, setPost] = useState(initialState)
  const { title, content } = post
  const router = useRouter()
  function onChange(e) {
    setPost(() => ({ ...post, [e.target.name]: e.target.value }))
  }
  async function createNewPost() {
    if (!title || !content) return
    const id = uuid()
    post.id = id

    await API.graphql({
      query: createPost,
      variables: { input: post },
      authMode: "AMAZON_COGNITO_USER_POOLS"
    })
    router.push(`/posts/${id}`)
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6">Create new post</h1>
      <input
        onChange={onChange}
        name="title"
        placeholder="Title"
        value={post.title}
        className="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      /> 
      <SimpleMDE value={post.content} onChange={value => setPost({ ...post, content: value })} />
      <button
        type="button"
        className="mb-4 bg-blue-600 text-white font-semibold px-8 py-2 rounded-lg"
        onClick={createNewPost}
      >Create Post</button>
    </div>
  )
}

export default withAuthenticator(CreatePost)

This will render a form and a markdown editor, allowing users to create new posts.

Next, create a new folder in the pages directory called posts and a file called [id].js within that folder. In pages/posts/[id].js, add the following code:

import { API } from 'aws-amplify'
import { useRouter } from 'next/router'
import ReactMarkdown from 'react-markdown'
import '../../configureAmplify'
import { listPosts, getPost } from '../../graphql/queries'

export default function Post({ post }) {
  const router = useRouter()
  if (router.isFallback) {
    return <div>Loading...</div>
  }
  return (
    <div>
      <h1 className="text-5xl mt-4 font-semibold tracking-wide">{post.title}</h1>
      <p className="text-sm font-light my-4">by {post.username}</p>
      <div className="mt-8">
        <ReactMarkdown className='prose' children={post.content} />
      </div>
    </div>
  )
}

export async function getStaticPaths() {
  const postData = await API.graphql({
    query: listPosts
  })
  const paths = postData.data.listPosts.items.map(post => ({ params: { id: post.id }}))
  return {
    paths,
    fallback: true
  }
}

export async function getStaticProps ({ params }) {
  const { id } = params
  const postData = await API.graphql({
    query: getPost, variables: { id }
  })
  return {
    props: {
      post: postData.data.getPost
    }
  }
}

This page uses getStaticPaths to dynamically create pages at build time based on the posts coming back from the API.

We also use the fallback flag to enable fallback routes for dynamic SSG page generation.

getStaticProps is used to enable the Post data to be passed into the page as props at build time.

Finally, update pages/index.js to add the author field and author styles:

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

export default function Home() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetchPosts()
  }, [])
  async function fetchPosts() {
    const postData = await API.graphql({
      query: listPosts
    })
    setPosts(postData.data.listPosts.items)
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">Posts</h1>
      {
        posts.map((post, index) => (
        <Link key={index} href={`/posts/${post.id}`}>
          <div className="cursor-pointer border-b border-gray-300	mt-8 pb-4">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p className="text-gray-500 mt-2">Author: {post.username}</p>
          </div>
        </Link>)
        )
      }
    </div>
  )
}

Deleting existing data

Now the app is ready to test out, but before we do let's delete the existing data in the database that does not contain an author field. To do so, follow these steps:

  1. Open the Amplify Console
$ amplify console api

> Choose GraphQL
  1. Click on Data sources
  2. Click on the link to the database
  3. Click on the Items tab.
  4. Select the items in the database and delete them by choosing Delete from the Actions button.

Next, run the app:

$ npm run dev

You should be able to create new posts and view them dynamically.

Running a build

To run a build and test it out, run the following:

$ npm run build

$ npm start

Adding a filtered view for signed in user's posts

In a future step, we will be enabling the ability to edit or delete the posts that were created by the signed in user. Before we enable that functionality, let's first create a page for only viewing the posts created by the signed in user.

To do so, create a new file called my-posts.js in the pages directory. This page will be using the postsByUsername query, passing in the username of the signed in user to query for only posts created by that user.

// pages/my-posts.js
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { API, Auth } from 'aws-amplify'
import { postsByUsername } from '../graphql/queries'

export default function MyPosts() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetchPosts()
  }, [])
  async function fetchPosts() {
    const { username } = await Auth.currentAuthenticatedUser()
    const postData = await API.graphql({
      query: postsByUsername, variables: { username }
    })
    setPosts(postData.data.postsByUsername.items)
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">My Posts</h1>
      {
        posts.map((post, index) => (
        <Link key={index} href={`/posts/${post.id}`}>
          <div className="cursor-pointer border-b border-gray-300	mt-8 pb-4">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p className="text-gray-500 mt-2">Author: {post.username}</p>
          </div>
        </Link>)
        )
      }
    </div>
  )
}

Updating the nav

Next, we need to update the nav to show the link to the new my-posts page, but only show the link if there is a signed in user.

To do so, we'll be using a combination of the Auth class as well as Hub which allows us to listen to authentication events.

Open pages/_app.js and make the following updates:

  1. Import the useState and useEffect hooks from React as well as the Auth and Hub classes from AWS Amplify:
import { useState, useEffect } from 'react'
import { Auth, Hub } from 'aws-amplify'
  1. In the MyApp function, create some state to hold the signed in user state:
const [signedInUser, setSignedInUser] = useState(false)
  1. In the MyApp function, create a function to detect and maintain user state and invoke it in a useEffect hook:
useEffect(() => {
  authListener()
})
async function authListener() {
  Hub.listen('auth', (data) => {
    switch (data.payload.event) {
      case 'signIn':
        return setSignedInUser(true)
      case 'signOut':
        return setSignedInUser(false)
    }
  })
  try {
    await Auth.currentAuthenticatedUser()
    setSignedInUser(true)
  } catch (err) {}
}
  1. In the navigation, add a link to the new route to show only if a user is currently signed in:
{
  signedInUser && (
    <Link href="/my-posts">
      <span className="mr-6 cursor-pointer">My Posts</span>
    </Link>
  )
}

Next, test it out by restarting the dev server:

npm run dev

Updating and deleting posts

Next, let's add a way for a signed in user to edit and delete their posts.

First, create a new folder named edit-post in the pages directory. Then, create a file named [id].js in this folder.

In this file, we'll be accessing the id of the post from a route parameter. When the component loads, we will then use the post id from the route to fetch the post data and make it available for editing.

In this file, add the following code:

// pages/edit-post/[id].js
import { useEffect, useState } from 'react'
import { API } from 'aws-amplify'
import { useRouter } from 'next/router'
import SimpleMDE from "react-simplemde-editor"
import "easymde/dist/easymde.min.css"
import { updatePost } from '../../graphql/mutations'
import { getPost } from '../../graphql/queries'

function EditPost() {
  const [post, setPost] = useState(null)
  const router = useRouter()
  const { id } = router.query

  useEffect(() => {
    fetchPost()
    async function fetchPost() {
      if (!id) return
      const postData = await API.graphql({ query: getPost, variables: { id }})
      setPost(postData.data.getPost)
    }
  }, [id])
  if (!post) return null
  function onChange(e) {
    setPost(() => ({ ...post, [e.target.name]: e.target.value }))
  }
  const { title, content } = post
  async function updateCurrentPost() {
    if (!title || !content) return
    await API.graphql({
      query: updatePost,
      variables: { input: { title, content, id } },
      authMode: "AMAZON_COGNITO_USER_POOLS"
    })
    console.log('post successfully updated!')
    router.push('/my-posts')
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">Edit post</h1>
      <input
        onChange={onChange}
        name="title"
        placeholder="Title"
        value={post.title}
        className="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      /> 
      <SimpleMDE value={post.content} onChange={value => setPost({ ...post, content: value })} />
      <button
        className="mb-4 bg-blue-600 text-white font-semibold px-8 py-2 rounded-lg"
        onClick={updateCurrentPost}>Update Post</button>
    </div>
  )
}

export default EditPost       

Next, open pages/my-posts.js. We'll make a few updates to this page:

  1. Create a function for deleting a post
  2. Add a link to edit the post by navigating to /edit-post/:postID
  3. Add a link to view the post
  4. Create a button for deleting posts

Update this file with the following code:

// pages/my-posts.js
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { API, Auth } from 'aws-amplify'
import { postsByUsername } from '../graphql/queries'
import { deletePost as deletePostMutation } from '../graphql/mutations'

export default function MyPosts() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetchPosts()
  }, [])
  async function fetchPosts() {
    const { username } = await Auth.currentAuthenticatedUser()
    const postData = await API.graphql({
      query: postsByUsername, variables: { username }
    })
    setPosts(postData.data.postsByUsername.items)
  }
  async function deletePost(id) {
    await API.graphql({
      query: deletePostMutation,
      variables: { input: { id } },
      authMode: "AMAZON_COGNITO_USER_POOLS"
    })
    fetchPosts()
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">My Posts</h1>
      {
        posts.map((post, index) => (
          <div key={index} className="border-b border-gray-300	mt-8 pb-4">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p className="text-gray-500 mt-2 mb-2">Author: {post.username}</p>
            <Link href={`/edit-post/${post.id}`}><a className="text-sm mr-4 text-blue-500">Edit Post</a></Link>
            <Link href={`/posts/${post.id}`}><a className="text-sm mr-4 text-blue-500">View Post</a></Link>
            <button
              className="text-sm mr-4 text-red-500"
              onClick={() => deletePost(post.id)}
            >Delete Post</button>
          </div>
        ))
      }
    </div>
  )
}

Enabling Incremental Static Generation

The last thing we need to do is implement Incremental Static Generation. Since we are allowing users to update posts, we need to have a way for our site to render the newly updated posts.

Incremental Static Regeneration allows you to update existing pages by re-rendering them in the background as traffic comes in.

To enable this, open pages/posts/[id].js and update the getStaticProps method with the following:

export async function getStaticProps ({ params }) {
  const { id } = params
  const postData = await API.graphql({
    query: getPost, variables: { id }
  })
  return {
    props: {
      post: postData.data.getPost
    },
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every second
    revalidate: 1 // adds Incremental Static Generation, sets time in seconds
  }
}

To test it out, restart the server or run a new build:

npm run dev

# or

npm run build && npm start

Adding a cover image with Amazon S3

Next, let's give users the ability to add a cover image to their post.

To do so, we need to do the following things:

  1. Add the storage category to the Amplify project.
  2. Update the GraphQL schema to add a coverImage field to the Post type
  3. Update the UI to enable users to upload images
  4. Update the UI to render the cover image (if it exists)

To get started, let's first open the GraphQL schema located at amplify/backend/api/NextBlog/schema.graphql and add a coverImage field:

type Post @model
  @key(name: "postsByUsername", fields: ["username"], queryField: "postsByUsername")
  @auth(rules: [
    { allow: owner, ownerField: "username" },
    { allow: public, operations: [read] }
  ]) {
  id: ID!
  title: String!
  content: String!
  username: String
  coverImage: String
}

Next, enable file storage by running the Amplify add command:

amplify add storage

? Please select from one of the below mentioned services: Content (Images, audio, video, etc.)
? Please provide a friendly name for your resource that will be used to label this category in the project: projectimages
? Please provide bucket name: <your-globally-unique-bucket-name>
? Who should have access: Auth and guest users
? What kind of access do you want for Authenticated users? create/update, read, delete
? What kind of access do you want for Guest users? read
? Do you want to add a Lambda Trigger for your S3 Bucket? No

Next, deploy the back end:

amplify push --y

Allowing users to upload a cover image

Next, let's enable the ability to upload a cover image when creating a post.

To do so, open pages/create-post.js.

We will be making the following updates.

  1. Adding a button to enable users to upload a file and save it in the local state
  2. Import the Amplify Storage category and useRef from React.
  3. When creating a new post, we will check to see if there is an image in the local state, and if there is then upload the image to S3 and store the image key along with the other post data.
  4. When a user uploads an image, show a preview of the image in the UI
// pages/create-post.js.
import { withAuthenticator } from '@aws-amplify/ui-react'
import { useState, useRef } from 'react' // new
import { API, Storage } from 'aws-amplify'
import { v4 as uuid } from 'uuid'
import { useRouter } from 'next/router'
import SimpleMDE from "react-simplemde-editor"
import "easymde/dist/easymde.min.css"
import { createPost } from '../graphql/mutations'

const initialState = { title: '', content: '' }

function CreatePost() {
  const [post, setPost] = useState(initialState)
  const [image, setImage] = useState(null)
  const hiddenFileInput = useRef(null);
  const { title, content } = post
  const router = useRouter()
  function onChange(e) {
    setPost(() => ({ ...post, [e.target.name]: e.target.value }))
  }
  async function createNewPost() {
    if (!title || !content) return
    const id = uuid() 
    post.id = id
    // If there is an image uploaded, store it in S3 and add it to the post metadata
    if (image) {
      const fileName = `${image.name}_${uuid()}`
      post.coverImage = fileName
      await Storage.put(fileName, image)
    }

    await API.graphql({
      query: createPost,
      variables: { input: post },
      authMode: "AMAZON_COGNITO_USER_POOLS"
    })
    router.push(`/posts/${id}`)
  }
  async function uploadImage() {
    hiddenFileInput.current.click();
  }
  function handleChange (e) {
    const fileUploaded = e.target.files[0];
    if (!fileUploaded) return
    setImage(fileUploaded)
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6">Create new post</h1>
      <input
        onChange={onChange}
        name="title"
        placeholder="Title"
        value={post.title}
        className="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      /> 
      {
        image && (
          <img src={URL.createObjectURL(image)} className="my-4" />
        )
      }
      <SimpleMDE value={post.content} onChange={value => setPost({ ...post, content: value })} />
      <input
        type="file"
        ref={hiddenFileInput}
        className="absolute w-0 h-0"
        onChange={handleChange}
      />
      <button
        className="bg-purple-600 text-white font-semibold px-8 py-2 rounded-lg mr-2" 
        onClick={uploadImage}        
      >
        Upload Cover Image
      </button>
      <button
        type="button"
        className="mb-4 bg-blue-600 text-white font-semibold px-8 py-2 rounded-lg"
        onClick={createNewPost}
      >Create Post</button>
    </div>
  )
}

export default withAuthenticator(CreatePost)

Now, users should be able to upload a cover image along with their post. If there is a cover image present, it will show them a preview.

Rendering the cover image in the detail view

Next, let's look at how to render the cover image. To do so, we need to check to see if the cover image key exists as part of the post. If it does, we will fetch the image from S3 and render it in the view.

Update pages/posts/[id].js with the following:

// pages/posts/[id].js 
import { API, Storage } from 'aws-amplify'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import ReactMarkdown from 'react-markdown'
import { listPosts, getPost } from '../../graphql/queries'

export default function Post({ post }) {
  const [coverImage, setCoverImage] = useState(null)
  useEffect(() => {
    updateCoverImage()
  }, [])
  async function updateCoverImage() {
    if (post.coverImage) {
      const imageKey = await Storage.get(post.coverImage)
      setCoverImage(imageKey)
    }
  }
  console.log('post: ', post)
  const router = useRouter()
  if (router.isFallback) {
    return <div>Loading...</div>
  }
  return (
    <div>
      <h1 className="text-5xl mt-4 font-semibold tracking-wide">{post.title}</h1>
      {
        coverImage && <img src={coverImage} className="mt-4" />
      }
      <p className="text-sm font-light my-4">by {post.username}</p>
      <div className="mt-8">
        <ReactMarkdown className='prose' children={post.content} />
      </div>
    </div>
  )
}

export async function getStaticPaths() {
  const postData = await API.graphql({
    query: listPosts
  })
  const paths = postData.data.listPosts.items.map(post => ({ params: { id: post.id }}))
  return {
    paths,
    fallback: true
  }
}

export async function getStaticProps ({ params }) {
  const { id } = params
  const postData = await API.graphql({
    query: getPost, variables: { id }
  })
  return {
    props: {
      post: postData.data.getPost
    }
  }
}

Allowing users the ability to update a cover image

Next, let's enable users to edit a post that contains a cover image. To do so, we'll need to enable similar functionality as we did when allowing users to create a post with a cover image.

We'll need to detect whether a post has a cover image, but also whether they have uploaded a new cover image and save the update if they have done so.

To implement this, update pages/edit-post/[id].js with the following code:

// pages/edit-post/[id].js
import { useEffect, useState, useRef } from 'react'
import { API, Storage } from 'aws-amplify'
import { useRouter } from 'next/router'
import SimpleMDE from "react-simplemde-editor"
import "easymde/dist/easymde.min.css"
import { v4 as uuid } from 'uuid'
import { updatePost } from '../../graphql/mutations'
import { getPost } from '../../graphql/queries'

function EditPost() {
  const [post, setPost] = useState(null)
  const router = useRouter()
  const { id } = router.query
  const [coverImage, setCoverImage] = useState(null)
  const [localImage, setLocalImage] = useState(null)
  const fileInput = useRef(null)

  useEffect(() => {
    fetchPost()
    async function fetchPost() {
      if (!id) return
      const postData = await API.graphql({ query: getPost, variables: { id }})
      console.log('postData: ', postData)
      setPost(postData.data.getPost)
      if (postData.data.getPost.coverImage) {
        updateCoverImage(postData.data.getPost.coverImage)
      }
    }
  }, [id])
  if (!post) return null
  async function updateCoverImage(coverImage) {
    const imageKey = await Storage.get(coverImage)
    setCoverImage(imageKey)
  }
  async function uploadImage() {
    fileInput.current.click();
  }
  function handleChange (e) {
    const fileUploaded = e.target.files[0];
    if (!fileUploaded) return
    setCoverImage(fileUploaded)
    setLocalImage(URL.createObjectURL(fileUploaded))
  }
  function onChange(e) {
    setPost(() => ({ ...post, [e.target.name]: e.target.value }))
  }
  const { title, content } = post
  async function updateCurrentPost() {
    if (!title || !content) return
    const postUpdated = {
      id, content, title
    }
    // check to see if there is a cover image and that it has been updated
    if (coverImage && localImage) {
      const fileName = `${coverImage.name}_${uuid()}` 
      postUpdated.coverImage = fileName
      await Storage.put(fileName, coverImage)
    }
    await API.graphql({
      query: updatePost,
      variables: { input: postUpdated },
      authMode: "AMAZON_COGNITO_USER_POOLS"
    })
    console.log('post successfully updated!')
    router.push('/my-posts')
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">Edit post</h1>
      {
        coverImage && <img src={localImage ? localImage : coverImage} className="mt-4" />
      }
      <input
        onChange={onChange}
        name="title"
        placeholder="Title"
        value={post.title}
        className="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      /> 
      <SimpleMDE value={post.content} onChange={value => setPost({ ...post, content: value })} />
      <input
        type="file"
        ref={fileInput}
        className="absolute w-0 h-0"
        onChange={handleChange}
      />
      <button
        className="bg-purple-600 text-white font-semibold px-8 py-2 rounded-lg mr-2" 
        onClick={uploadImage}        
      >
        Upload Cover Image
      </button>
      <button
        className="mb-4 bg-blue-600 text-white font-semibold px-8 py-2 rounded-lg"
        onClick={updateCurrentPost}>Update Post</button>
    </div>
  )
}

export default EditPost 

Now, users should be able to edit the cover image if it exists, or add a cover image for posts that do not contain one.

Rendering a cover image thumbnail preview

The last thing we may want to do is give a preview of the cover image in the list of posts on the main index page.

To do so, let's update our code to see if there is a cover image associated with each post. If there is, we'll fetch the image from S3 and then render the post image if it exists.

To implement this, open pages/index.js and update it with the following code:

// pages/index.js
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { API, Storage } from 'aws-amplify'
import { listPosts } from '../graphql/queries'

export default function Home() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetchPosts()
  }, [])
  async function fetchPosts() {
    const postData = await API.graphql({
      query: listPosts
    })
    const { items } = postData.data.listPosts
    // Fetch images from S3 for posts that contain a cover image
    const postsWithImages = await Promise.all(items.map(async post => {
      if (post.coverImage) {
        post.coverImage = await Storage.get(post.coverImage)
      }
      return post
    }))
    setPosts(postsWithImages)
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-8">Posts</h1>
      {
        posts.map((post, index) => (
        <Link key={index} href={`/posts/${post.id}`}>
          <div className="my-6 pb-6 border-b border-gray-300	">
            {
              post.coverImage && <img src={post.coverImage} className="w-56" />
            }
            <div className="cursor-pointer mt-2">
              <h2 className="text-xl font-semibold">{post.title}</h2>
              <p className="text-gray-500 mt-2">Author: {post.username}</p>
            </div>
          </div>
        </Link>)
        )
      }
    </div>
  )
}

If you'd like to also have the same functionality to preview cover images in the my-posts.js view, try adding the same updates there.

Deployment with amplify

To deploy to AWS hosting, follow the guide laid out here

Removing Services

If at any time, or at the end of this workshop, you would like to delete a service from your project & your account, you can do this by running the amplify remove command:

$ amplify remove auth

$ amplify push

If you are unsure of what services you have enabled at any time, you can run the amplify status command:

$ amplify status

amplify status will give you the list of resources that are currently enabled in your app.

Deleting the Amplify project and all services

If you'd like to delete the entire project, you can run the delete command:

$ amplify delete

next.js-amplify-workshop's People

Contributors

dabit3 avatar fokkezb avatar gmlnchv avatar ncmbch avatar weisisheng avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

next.js-amplify-workshop's Issues

Missing Dependency

Excellent tutorial.

Also more as a heads up there is an issue with amazon cloud9 IDE (default) and it's detailed in tailwind issue Issue: 2810

(it reports as easymde):

./node_modules/easymde/dist/easymde.min.css
TypeError: getProcessedPlugins is not a function

I thought the real problem is Node 10x is installed by default on cloud9 IDE machines. It is made note of in the prerequisites which was read but not actually checked on the EC2 machine. To correct, I used the following guide.
Upgrade Node

However the build problems continued, and this is where I became jammed up.

ReferenceError: navigator is not defined
    at /home/ec2-user/environment/node_modules/codemirror/lib/codemirror.js:18:19
    at /home/ec2-user/environment/node_modules/codemirror/lib/codemirror.js:11:83
    at Object.<anonymous> (/home/ec2-user/environment/node_modules/codemirror/lib/codemirror.js:14:2)
    at Module._compile (node:internal/modules/cjs/loader:1109:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1138:10)
    at Module.load (node:internal/modules/cjs/loader:989:32)
    at Function.Module._load (node:internal/modules/cjs/loader:829:14)
    at Module.require (node:internal/modules/cjs/loader:1013:19)
    at require (node:internal/modules/cjs/helpers:93:18)
    at Object.<anonymous> (/home/ec2-user/environment/node_modules/easymde/src/js/easymde.js:2:18) {
  type: 'ReferenceError'
}

I have tried:
npm install codemirror
with the import: import CodeMirror from 'codemirror'; in both create-post.js and edit-post/[id].js

I have also tried:
npm install easymde

Any help would be appreciated.

Update: I have narrowed this down to this include
import SimpleMDE from "react-simplemde-editor

Update 2: I have also tried:
uiwjs/react-md-editor
Also failed to compile.

Graphql Transformer V2 changes

There have been the following changes:

@key is obsolete and needs to be replaced with @Index. @Index needs to be on the same line as the field, therefore "fieldName" isn't used any longer.

type Post @model
  @auth(rules: [
    { allow: owner, ownerField: "username" },
    { allow: public, operations: [read] }
  ]) {
  id: ID!
  title: String!
  content: String!
  username: String @index(name: "postsByUsername" , queryField: "postsByUsername")
} 

When I was running amplify push --y to update, it had an error and rolled back. It was necessary to:

Copy the data in the schema.graphql to keep it in the Clipboard. And then:

  • amplify api remove YourAPIName
  • amplify api add
  • amplify push

Of course, you'll lose all previously stored data. Not sure how this would work in production? Still some big question marks.

CredentialsError: Missing credentials in config

I've reached the step npx serverless and am seeing the following error

nextamplified › CredentialsError: Missing credentials in config

Would really appreciate it if someone could help with this.

Thanks!

amplify/react-ui V3 changes - Profile.js

Seems there are breaking changes from V2 to V3 of the amplify react-ui. I got errors.

Error 1: No styling applied to authentication flow amplify components

By default, no styling is applied any longer to components by default. In order to apply styling, following import is required:

import '@aws-amplify/ui-react/styles.css';

Error 2: AmplifySignout obsolete (throws an import error)

Define a parameter called signOut in the Profile function:

function Profile({ signOut }) {

Add a button instead of the component:

 <button onClick={signOut}>Sign out</button>

The withAuthenticator seems to pass a function to the signOut parameter.

Full example

import React from 'react';
import { withAuthenticator } from '@aws-amplify/ui-react'
import { Auth } from 'aws-amplify'
import { useState, useEffect } from 'react'
import '@aws-amplify/ui-react/styles.css';

function Profile({ signOut }) {
  const [user, setUser] = useState(null)

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

  async function checkUser() {
    const user = await Auth.currentAuthenticatedUser()
    setUser(user)
  }

  if (!user) return null
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6">Profile</h1>
      <h3 className="font-medium text-gray-500 my-2">Username: {user.username}</h3>
      <p className="text-sm text-gray-500 mb-6">Email: {user.attributes.email}</p>
      <button onClick={signOut}>Sign out</button>
    </div>
  )
}

export default withAuthenticator(Profile)

Double check: As currently coded (20201226), this can't be hosted using the git push to AWS?

My builds get an error:

2020-12-26T06:50:50.209Z [INFO]: info - Launching 3 workers
2020-12-26T06:50:50.210Z [WARNING]: Error: Found pages with fallback enabled:
/posts/[id]
Pages with fallback enabled in getStaticPaths can not be exported. See more info here: https://err.sh/next.js/ssg-fallback-true-export
at exportApp (/codebuild/output/src968109813/src/nader-nextjs-blog-auth-example/node_modules/next/dist/export/index.js:24:196)

Wanted to make sure I wasn't missing anything since I am new to SSR apps. Thanks.

JavaScript heap out of memory

Greetings,
Great tutorial as usual by Naber, so thankfull.

I wonder if any one else has been having this issue though. Error happend when I was half throw the tutorial, just in dev mode.
I know this has nothing to do with this tutorial I'm just posting to know if it did happend to someone else, seems like a amplify console related issue.

OS: Windows 10.
Editor: VSCode
App: NextJS, TailwindCSS
Node 14LTS

<--- Last few GCs --->

[46644:000001875BA2FE60] 7999109 ms: Mark-sweep (reduce) 4078.1 (4104.2) -> 4077.5 (4103.7) MB, 3561.1 / 0.0 ms (average mu
= 0.096, current mu = 0.000) allocation failure scavenge might not succeed
[46644:000001875BA2FE60] 8000736 ms: Mark-sweep (reduce) 4078.5 (4100.7) -> 4078.3 (4102.4) MB, 1625.7 / 0.1 ms (average mu
= 0.063, current mu = 0.001) allocation failure scavenge might not succeed

<--- JS stacktrace --->

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
1: 00007FF64F09021F napi_wrap+109311
2: 00007FF64F035286 v8::internal::OrderedHashTablev8::internal::OrderedHashSet,1::NumberOfElementsOffset+33302
3: 00007FF64F036056 node::OnFatalError+294
4: 00007FF64F90054E v8::Isolate::ReportExternalAllocationLimitReached+94
5: 00007FF64F8E53CD v8::SharedArrayBuffer::Externalize+781
6: 00007FF64F78F85C v8::internal::Heap::EphemeronKeyWriteBarrierFromCode+1516
7: 00007FF64F79AB9A v8::internal::Heap::ProtectUnprotectedMemoryChunks+1258
8: 00007FF64F797D49 v8::internal::Heap::PageFlagsAreConsistent+2457
9: 00007FF64F78C971 v8::internal::Heap::CollectGarbage+2033
10: 00007FF64F78AB75 v8::internal::Heap::AllocateExternalBackingStore+1317
11: 00007FF64F7AAF67 v8::internal::Factory::NewFillerObject+183
12: 00007FF64F4DAFF1 v8::internal::interpreter::JumpTableTargetOffsets::iterator::operator=+1409
13: 00007FF64F988EFD v8::internal::SetupIsolateDelegate::SetupHeap+463949
14: 000000EF75E8499F
error Command failed with exit code 134.

Not able to create post

I'm following along the tutorial until the point where creating a post is implemented. I'm not able to create a post, seeing a GraphQLError and the following message:

You are not authorized to make this call

Could you please help? I don't understand why I'm seeing this.

Thanks!

Build error occurred Error: No credentials

Hi Nader,

Thank you for putting this workshop together.

I was able to follow along but ran into a bit of an issue when I tried to run build on the next.js app. Here's the error I keep getting when I run build or deploy to Amplify:

GraphQLAPI - ensure credentials error No Cognito Identity pool provided for unauthenticated access

> Build error occurred
Error: No credentials
    at GraphQLAPIClass.<anonymous> (/Users/peterhironaka/prototypes/amplify-next/node_modules/@aws-amplify/api-graphql/lib/GraphQLAPI.js:198:35)

Where should I be looking to fix this? I added Amazon Cognito Identity Pool for unauthenticated users.

Any help would be appreciated.

Path fun with Windows

For amplify init, using Windows Terminal at least, the default src should be used, not . (assuming the command is run from the $/amplify-next/ project directory. Changing src to . breaks everything.

Also I expect this is a CLI error but FWIW amplify console api gives this error. I can browse to the test page fine via the AWS console so it's no big deal.

(node:469) UnhandledPromiseRejectionWarning: Error: spawn wslvar ENOENT
    at Process.ChildProcess._handle.onexit (internal/child_process.js:267:19)
    at onErrorNT (internal/child_process.js:469:16)
    at processTicksAndRejections (internal/process/task_queues.js:84:21)
(node:469) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:469) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

Slow on Post (new or edited)

How do I speed this up? I tried cutting down revalidate: from 60 to 1, and then just eliminated it all together. That helped a little but it is still far too slow.

The navigation is fast, text enter for the post title is fast. It seems to be limited to the post text body that is quite slow.

Update: I left it running overnight hoping the serverless approach would speed up. Alas it did not. So I removed the simpleMDE references / approach and replaced it with:

      <input
        onChange={onChange}
        name="content"
        placeholder="Notes"
        value={post.content}
        className="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      />

This is much better user experience (in terms of speed), and it works. Is there any reason why I should handle the title field differently than the content field?

incorrect paths for aws-exports and graphql

just a heads up that I got errors initially - Module not found: Can't resolve './aws-exports' - It was in the /src folder not the root. I edited the one place that was calling this to add /src to the path.

Once that worked then I got errors for the graphql path - Module not found: Can't resolve '../graphql/queries' - same thing it was in /src so I just moved it to the root and now it is working.

I'm on windows.

This all new to me so not sure where the path was set wrong.

thanks for the tutorial!

React dependency error

I'm seeing an error due to React 17 being used in the root project while @aws-amplify/[email protected] depends on React 16. Is the option --legacy-peer-deps as suggested in the terminal ok to use?

Thanks!

whey didn't you use DataStore in your app?

Hey, Great example by the way.

I think Datastore just helps with offline data load. I have one more question. How is this serverless deployment different than deployment through Amplify frontend?

Query postsByUsername return empty items

In latest version of Amplify CLI (v10), there is a breaking changes that need to be address for anyone who is following this tutorial.

In addition to the changes mentioned in this issue #25, you will also need to update your schema.graphql file with the following code to ensure the query posts by username works.

type Todo
  @model
  @auth(
    rules: [
      {
        allow: owner
        identityClaim: "username" # explicit identity claim
      }
    ]
  ) {
  id: ID!
  title: String!
}

The changes is mentioned in this page:
Manual changes to continue using "username" for existing apps

This is my final code for graphql

type Post
  @model
  @auth(
    rules: [
      { allow: owner, ownerField: "username", identityClaim: "username" }
      { allow: public, operations: [read] }
    ]
  ) {
  id: ID!
  title: String!
  content: String!
  username: String!
    @index(name: "postsByUsername", queryField: "postsByUsername")
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.