In this blog post we will go through building a server-rendered realtime collaborative todo list app with Next.js and AWS Amplify.
You can check out the final code here and a demo here.
- Introduction
- Creating our app skeleton
- Adding offline functionality
- Preparing the Graqhql Schema for Amplify GraphQL Transform
- Setting up AWS Amplify on your computer
- Creating the API
- Editing the backend
- Saving Todos in the cloud
- Fetching initial todos on the server-side
- Listening to todos being added by others
- Listening to todos modified and deleted by others
- Deploying our app with now
Introduction
The app will have dynamic and static routes to demonstrate how to load and render data from the server based on the incoming request url. And it has subscriptions to changes on the data to show how to use AWS Amplify to seamlessly listen to remote data from the client.
Next.js makes server-side rendering easy wherever your data is coming from.
AWS Amplify is a library and toolchain that makes it a breeze to setup, manage and use infinitely scale-able cloud infrastructure from AWS.
You don't need to be familiar with the rest of AWS services to use it, however, if you are, you'll notice that Amplify offers a layer of abstraction over popular and battle tested AWS cloud services like AppSync, DynamoDB, Cognito, Lambda, S3 and many others. Amplify packages these cloud services under categories such as Analytics, Auth, API, Storage, PubSub... If you would like to know more about it, make sure to check out their website.
Please note that you can deploy a production ready app without ever needing to know or manually manage any of these services. AWS Amplify can be your only contact point with the cloud.
With that said, let's get started !
Creating our app skeleton
First, let's set up a directory and initialize it with git
mkdir todo-list
cd todo-list
npm init -y
git init
By now we have a directory that contains only our package.json with the defaults specified.
We can now install our dependencies
npm i react react-dom next immer nanoid
# If you're using typescript
npm i -D typescript -@types/react @types/react-dom @types/node
Note that the immer and nanoid dependencies are not necessary
but immer will make it easier for us to manipulate React state and
nanoid is a tiny util to generate a unique id for each to do.
And add 3 scripts to our package.json
{
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
}
Next, we need to create a main page for the web application,
when using Next.js we just need to create a directory called pages and put in it our main file as index.js (or index.tsx)
mkdir pages
touch pages/index.js # or pages/index.tsx
Our main page will just return the app shell to confirm our setup is correct.
import * as React from "react";
const App = () => {
return (
<>
<header>
<h2>To Do List</h2>
</header>
<main>Hello World</main>
</>
);
};
export default App;
Let's run it now :
npm run dev
Next.js will setup a tsconfig for us (if we're using Typescript) and start a server on localhost:3000
Visiting that should give us something like this :
Adding offline functionality
We're now ready to add the functionality for our app.
It should have a text field with a button next to it and a list of edit-able and delete-able todos.
To manage the state we will use React.useReducer
with initial state equal to :
{
currentTodo:"",
todos: []
}
and the reducer will support 4 actions add
, update
, set-current
and delete
Looking at some code, our reducer :
import produce from "immer";
/*<IfTypescript>*/
type Todo = {
id: string;
name: string;
createdAt: string;
completed: boolean;
};
type State = { todos: Todo[]; currentTodo: string };
type Action =
| { type: "add" | "update" | "delete"; payload: Todo }
| { type: "set-current"; payload: string };
/*</IfTypescript>*/
const reducer /*: React.Reducer<State, Action>*/ = (state, action) => {
switch (action.type) {
case "set-current": {
return produce(state, draft => {
draft.currentTodo = action.payload;
});
}
case "add": {
return produce(state, draft => {
draft.todos.push(action.payload);
});
}
case "update": {
const todoIndex = state.todos.findIndex(
todo => todo.id === action.payload.id
);
if (todoIndex === -1) return state;
return produce(state, draft => {
draft.todos[todoIndex] = { ...action.payload };
});
}
case "delete": {
const todoIndex = state.todos.findIndex(
todo => todo.id === action.payload.id
);
if (todoIndex === -1) return state;
return produce(state, draft => {
draft.todos.splice(todoIndex, 1);
});
}
default: {
throw new Error(`Unsupported action ${JSON.stringify(action)}`);
}
}
};
And the UI component :
const App = () => {
// The reducer defined before
const [state, dispatch] = React.useReducer(reducer, {
currentTodo: "",
todos: []
});
const add = () => {
dispatch({
type: "add",
payload: {
id: nanoid(),
name: state.currentTodo,
completed: false,
createdAt: `${Date.now()}`
}
});
dispatch({ type: "set-current", payload: "" });
};
const edit = (todo /*:Todo*/) => {
dispatch({ type: "update", payload: todo });
};
const del = (todo /*:Todo*/) => {
dispatch({ type: "delete", payload: todo });
};
return (
<>
<header>
<h2>To Do List</h2>
</header>
<main>
<form
onSubmit={event => {
event.preventDefault();
add(state.currentTodo);
}}
>
<input
type="text"
value={state.currentTodo}
onChange={event => {
dispatch({ type: "set-current", payload: event.target.value });
}}
/>
<button type="submit">Add</button>
</form>
<ul>
{state.todos.map(todo => {
return (
<li key={todo.id}>
<input
type={"text"}
value={todo.name}
onChange={event => {
edit({ ...todo, name: event.target.value });
}}
/>
<button
onClick={() => {
del(todo);
}}
>
Delete
</button>
</li>
);
})}
</ul>
</main>
</>
);
};
At this point we have a working to do list app that works offline.
If you're following along with code, now might be a good time to create a commit before jumping into integrating our app with AWS Amplify.
Before you commit make sure to add a .gitignore file
printf "node_modules\n.next" > .gitignore
Let's now sync our todos with the cloud to be able to share them and collaborate with others.
Preparing the Graqhql Schema for Amplify GraphQL Transform
Let's very quickly go through what Amplify GraphQL Transform is.
The GraphQL Transform provides a simple to use abstraction
that helps you quickly create backends for your web and mobile applications on AWS.
With it we define our data model using the GraphQL SDL and the amplify cli takes care of :
- Provisioning/Updating required infrastructure for CRUDL operations.
- Generating code for client-side CRUDL-ing
Input : GraphQL Data Shape.
Output: Elastic Infrastructure and code to seamless-ly interact with it.
CRUDL = Create Read Update Delete List
In our case the GraphQL schema is simple it consists of one Todo type and one TodoList type that contains a sorted list of todos :
type Todo @model {
# ! means non-null GraphQL fields are allowed to be null by default
id: ID!
name: String!
createdAt: String!
completed: Boolean!
todoList: TodoList! @connection(name: "SortedList")
userId: String!
}
type TodoList @model {
id: ID!
createdAt: String!
# Array of Todos sorted by Todo.createdAt
todos: [Todo] @connection(name: "SortedList", sortField: "createdAt")
}
We store the schema as
schema.graphql
to be re-used later.
The @model
directive in the GraphQL Transform schema tells Amplify to treat the to do as a model and store objects of that type in DynamoDB and automatically configure CRUDL queries and mutations using AppSync.
The @connection
directive allows us to specify n-to-n relationships between our data types and sort it on the server-side.
Read more about GraphQL Transform and supported directives here.
If you've already used Amplify you can skip directly to Creating the API
Setting up AWS Amplify on your computer
- Sign up for an AWS account
- Install the AWS Amplify cli:
npm install -g @aws-amplify/cli
- Configure the Amplify cli
amplify configure
Creating the API
We start by initializing amplify in our project.
npm i aws-amplify
amplify init
#<Interactive>
? Enter a name for the project (todolist) todolist
? Enter a name for the environment dev # or prod
? Choose your default editor: <MY_FAVORITE_EDITOR>
? Choose the type of app that you\'re building javascript # even if you're using typescript
? What javascript framework are you using react
? Source Directory Path: src
? Distribution Directory Path: out # Next.js exports to the out directory
? Build Command: npm run-script build
? Start Command: npm run-script start
? Do you want to use an AWS profile? (Y/n) Y # Or use default
? Please choose the profile you want to use default
Your project has been successfully initialized and connected to the cloud!
# 🚀 Ready
#</Interactive>
At this point 2 new folders should have been created : src
and amplify
It's safe to ignore them for now.
Now that amplify is initialized we can add any of its services (Auth, API, Analytics ...)
For our use-case we just need to use the API module. So we add it to the project using :
amplify add api
? Please select from one of the below mentioned services GraphQL
? Provide API name: todolist
? Choose an authorization type for the API (Use arrow keys)
❯ API key
Amazon Cognito User Pool
? Do you have an annotated GraphQL schema? (y/N) y # The one we saved earlier to schema.graphql
? Provide your schema file path: ./schema.graphql
The API configuration is ready we need to push to sync our cloud resources with the current configuration :
amplify push
? Are you sure you want to continue? (Y/n) Y
? Do you want to generate code for your newly created GraphQL API (Y/n) Y # This code incredibly speeds up development
? Choose the code generation language target
❯ javascript
typescript
flow
? 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 (Y/n) Y
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
⠼ Updating resources in the cloud. This may take a few minutes...
# Logs explaining what's happening
✔ Generated GraphQL operations successfully and saved at src/graphql
✔ All resources are updated in the cloud
GraphQL endpoint: https://tjefk2x675ex7gocplim46iriq.appsync-api.us-east-1.amazonaws.com/graphql
GraphQL API KEY: da2-d7hytqrbj5cwfgbbnxavvm7xry
And that's it 🎉 ! Our whole backend is ready and we have the client-side code to query it.
Editing the backend
- Edit
amplify/backend/api/apiname/schema.graphql
. - Run
amplify push
- That's it 👍
Saving Todos in the cloud
In pages/index We start by importing API
and graphqlOperation
from aws-amplify
and configure our amplify application with src/aws-exports.js
import { API, graphqlOperation } from "aws-amplify";
import config from "../src/aws-exports";
API.configure(config);
// Should be a device id or a cognito user id but this will do
const MY_ID = nanoid();
Next, if you open src/graphql/mutations
you'll see there's a createTodo string containing the GraphQL Mutation to create a new todo.
We import it and use it after dispatching the add
action.
const add = async () => {
const todo = {
id: nanoid(),
name: state.currentTodo,
completed: false,
createdAt: `${Date.now()}`
};
dispatch({
type: "add",
payload: todo
});
// Optimistic update
dispatch({ type: "set-current", payload: "" });
try {
await API.graphql(
graphqlOperation(createTodo, {
input: { ...todo, todoTodoListId: "global", userId: MY_ID }
})
);
} catch (err) {
// With revert on error
dispatch({ type: "set-current", payload: todo.name });
}
};
And that's it our todos are now being saved to a highly available DynamoDB instance billed by request.
Fetching initial todos on the server-side
We want the list we're building and the data in it to be server-rendered and sent to the client.
So we can't use the React.useEffect hook to load the data and store it in state.
Using Next.js's getInitialProps
async method we can fetch data from anywhere and pass it as props to our page component.
Adding one to our main page would look like this
import { getTodoList, createTodoList } from "../src/graphql/queries";
// <TypescriptOnly>
import { GetTodoListQuery } from "../src/API";
// </TypescriptOnly>
App.getInitialProps = async () => {
let result; /*: { data: GetTodoListQuery; errors: {}[] };*/
try {
// Fetch our list from the server
result = await API.graphql(graphqlOperation(getTodoList, { id: "global" }));
} catch (err) {
console.warn(err);
return { todos: [] };
}
if (result.errors) {
console.warn("Failed to fetch todolist. ", result.errors);
return { todos: [] };
}
if (result.data.getTodoList !== null) {
return { todos: result.data.getTodoList.todos.items };
}
try {
// And if it doesn't exist, create it
await API.graphql(
graphqlOperation(createTodoList, {
input: {
id: "global",
createdAt: `${Date.now()}`
}
})
);
} catch (err) {
console.warn(err);
}
return { todos: [] };
};
And in our App component we initialize our state with the props we sent with getInitialProps
//<TypescriptOnly>
import { GetTodoListQuery } from '../src/API'
type Props = {
todos: GetTodoListQuery["getTodoList"]["todos"]["items"];
}
//</TypescriptOnly>
const App = ({ todos }/*:Props */) => {
const [state, dispatch] = React.useReducer(reducer, {
currentTodo: "",
todos
});
If you try refreshing the page now, you should see that your todos are persisted between refreshs and they're sorted in the same order as they were before when they were added
Listening to todos being added by others
After we render the app on the client we want to listen to data changes that originated from other users so we can update our UI accordingly.
We will be using GraphQL subscriptions to listen to when a todo is added, updated or deleted.
Fortunately this won't take more than a couple of lines to setup.
import { onCreateTodo } from "../src/graphql/subscriptions";
/*
With TS we create an Observable type to describe the return type of a GraphQL subscription.
Hopefully in future releases of aws-amplify we will have generic types for API.graphql that will make this un-necessary.
*/
type Observable<Value = unknown, Error = {}> = {
subscribe: (
cb?: (v: Value) => void,
errorCb?: (e: Error) => void,
completeCallback?: () => void
) => void;
unsubscribe: Function;
};
// In our function component
const App = props => {
// bla
React.useEffect(() => {
const listener /*: Observable<{
value: { data: OnCreateTodoSubscription };
}> */ = API.graphql(graphqlOperation(onCreateTodo));
const subscription = listener.subscribe(v => {
if (v.value.data.onCreateTodo.userId === MY_ID) return;
dispatch({ type: "add", payload: v.value.data.onCreateTodo });
});
return () => {
subscription.unsubscribe();
};
}, []);
// blabla
};
Listening to todos modified and deleted by others
We'll start by subscribing to two new subscriptions onUpdateTodo
and onDeleteTodo
import {
onCreateTodo,
onUpdateTodo,
onDeleteTodo
} from "../src/graphql/subscriptions";
// <ts>
import { OnUpdateTodoSubscription, OnDeleteTodoSubscription } from "../src/API";
type Listener<T> = Observable<{ value: { data: T } }>;
// </ts>
// In our function component
const App = props => {
// bla
React.useEffect(() => {
const onCreateListener: Listener<OnCreateTodoSubscription> = API.graphql(
graphqlOperation(onCreateTodo)
);
const onUpdateListener: Listener<OnUpdateTodoSubscription> = API.graphql(
graphqlOperation(onUpdateTodo)
);
const onDeleteListener: Listener<OnDeleteTodoSubscription> = API.graphql(
graphqlOperation(onDeleteTodo)
);
const onCreateSubscription = onCreateListener.subscribe(v => {
if (v.value.data.onCreateTodo.userId === MY_ID) return;
dispatch({ type: "add", payload: v.value.data.onCreateTodo });
});
const onUpdateSubscription = onUpdateListener.subscribe(v => {
dispatch({ type: "update", payload: v.value.data.onUpdateTodo });
});
const onDeleteSubscription = onDeleteListener.subscribe(v => {
dispatch({ type: "delete", payload: v.value.data.onDeleteTodo });
});
return () => {
onCreateSubscription.unsubscribe();
onUpdateSubscription.unsubscribe();
onDeleteSubscription.unsubscribe();
};
}, []);
// blabla
};
And here's what our end result, a collaborative real-time todo list looks like
Our first page is done but we still need to have our individual todo page and link to it from our list.
We need our individual todos to be indexed by search engines so we will need to server-render the data in the todo from the id in the url.
To do that, we create a new Next.js dynamic route in pages/todo/[id].(t|j)sx
and use the getInitialProps
async method to populate it with data from our AWS Amplify datasource.
import * as React from "react";
import { API, graphqlOperation } from "aws-amplify";
import { getTodo } from "../../src/graphql/queries";
import config from "../../src/aws-exports";
// <ts>
import { GetTodoQuery } from "../../src/API";
type Props = { todo: GetTodoQuery["getTodo"] };
// </ts>
API.configure(config);
const TodoPage = (props /*: Props*/) => {
return (
<div>
<h2>Individual Todo {props.todo.id}</h2>
<pre>{JSON.stringify(props.todo, null, 2)}</pre>
</div>
);
};
TodoPage.getInitialProps = async context => {
const { id } = context.query;
try {
const todo = (await API.graphql({
...graphqlOperation(getTodo),
variables: { id }
})) as { data: GetTodoQuery; errors?: {}[] };
if (todo.errors) {
console.log("Failed to fetch todo. ", todo.errors);
return { todo: {} };
}
return { todo: todo.data.getTodo };
} catch (err) {
console.warn(err);
return { todo: {} };
}
};
export default TodoPage;
And last, we add a link to every todo item
<a href={`/todo/${todo.id}`}>Visit</a>
Deploying our app with now
There are 2 ways of deploying a Next.js app :
- Export it to html and static assets and serve it from anywhere
- Run a node server that fetches the data on every request and serves pre-rendered pages
We can't export our project to a static html app because we have a dynamic route todo/[id]
that fetches data on the fly before rendering based on the url and our main route needs the latest todos to pre-render.
Without these constraints, exporting would be as simple as running : next build && next export
.
The other way, which we will be using, is to deploy it as we would any node server.
The fastest way to deploy a Node.js server is using now.
We add a now.json
file with the following contents :
{
"version": 2,
"builds": [{ "src": "package.json", "use": "@now/next" }]
}
Read more about
now.json
.
And we can then deploy with
now
And that's it !
We have built and deployed an SEO friendly server-side rendered collaborative todo list using Next.js and AWS Amplify.
👋 If you have any questions feel free to comment here or ping me on twitter.
Top comments (23)
How to deploy Nextjs frontend (SSR) in amplify console?
You will need to do :
This will generate a static version of your app in the
out
directoryThen point Amplify Console to the out directory by setting artifacts -> baseDirectory in your amplify config : docs.aws.amazon.com/amplify/latest...
I did same but still access denied error page on my production branch url - master.d1pgdow2brm58j.amplifyapp.com/
Ya I also have access denied when done generate the page. When start SSR build, it go to Access Denied.
I have attached
AdminstratorAccess
to the service role, but it still the same.Do you have any fix on this??
Thank you. Can I deploy Nextjs API with this?
No, unfortunately you can't deploy the API directory like this, this is only for your static assets and for the api calls you will need a node server. You can use zeit's now to easily deploy the API, alternatively you can use any provider that supports node apps deployment and then running npm run build && npm run start.
Why is the title of this article "Server-Side Rendered Real-time Web App with Next.js, AWS Amplify" if Amplify doesn't support SSR? 🤔
Amplify Console doesn't support SSR but you can use the Amplify API to populate the data for your page on the server side
ts -v = 3.6.2
reactjs -v = 16.9.2
Line 64 in my file, on the add() method definition (at add.payload.name), was getting a "cannot find name 'currentTodo'" error.
Changed name: currentTodo => name: state.currentTodo
add methodThen, deleted state.currentTodo entirely from
works ok now... I'm brand new to typescript & React Hooks so not sure if those changes will bite me later but so far good project to learn these...
Thanks for reporting that, I updated the post to fix it 👍
I love this. I’ve used prisma.io for graphql a lot before but I want to try amplify appsync. The only thing I think it’s missing is how to handle what the user can or can’t do when using the queries or mutations. Do you know any good read for this?
That's true it doesn't go into how to handle auth and user permissions. I would recommend checking out the chatt app codebase here It's complete and written very clearly and make sure to read the overview post here.
Awesome tutorial! I don't know why but for the subscriptions to work I had to configure PubSub with
PubSub.configure(config);
I also think the subscribe function in the observable type should return an object with an unsubscribe function.
Thanks for this... one thing that has gotten immensely confusing as I walk through this is where
src/API
comes from inimport { GetTodoListQuery } from "../src/API";
.Locally, that does not exist.
amplify configure codegen
Awesome, thanks! Noticed your reducer code omits the
set-current
method, although this is included in the repo. Really nice writeup, makes me want to learn more about Amplify !Thanks for catching that, I updated the post 👍
I was literally pulling my hair off yesterday to understand how getInitialProps works with AppSync, thank you very much for this step by step.
Same happened to me, that's why I decided to write it :D
Happy it helps
To be clear, you can't actually host full Next.js SSR sites on AWS Amplify - only static exported versions.
Is there any way to deploy it on AWS. if not then what are the good alternatives.
That answer is out of date. You can now. aws.amazon.com/blogs/mobile/host-a...
Thanks for the update. :)