Building an i18n serverless video resource platform with AWS Amplify & Next.js

Building an i18n serverless video resource platform with AWS Amplify & Next.js

Essentially a serverless YouTube / Netflix platform

Β·

25 min read

Greetings to all fellow AWS Amplify hackers from Cambridge, UK!

In this post, I'll be going over some of the details for one of my latest AWS Amplify projects; a serverless i18n Next.js video-on-demand (VOD) platform πŸ˜…

Contents

Getting started with AWS Amplify / Next.js

If you're not new to Next.js or setting up the Amplify CLI (command-line interface) then you can skip ahead!

Setting up a new Next.js website

Before setting your site make sure you've got the Next.js requirements installed - see the requirements and the official Next.js docs here.

yarn create next-app

Once you've run the above you'll have a boilerplate Next.js site ready to go! You'll want to refer to the official docs for a better understanding of Next.js as it's out-of-scope for this blog.

Setting up the AWS Amplify CLI

Setting up the AWS Amplify is quick and easy! Make sure you're inside the root of your project folder before running any of the following commands.

Install the CLI globally with NPM

npm install -g @aws-amplify/cli

Once again, for more detail please refer to the great documentation that the Amplify team has put so much time into, here.

Setup Amplify for your Next.js project

Run amplify configure to set up the Amplify CLI to talk to your AWS Account.

amplify configure

When running amplify init, the AWS Amplify CLI will create a new amplify folder in the root of your project. Amplify uses this to store the CloudFormation templates that describe your back-end infrastructure. When using the AWS Amplify project, you'll become familiar with the structure of resources inside this folder in order to write serverless Lambda functions, custom CloudFormation resources, Lambda Layers and more.

amplify init

Hosting a Next.js site with AWS

As of writing this blog, there is currently no way to set up the necessary AWS resources for hosting a Next.js site with AWS Amplify directly. Instead, we'll be using an awesome Serverless Framework Next.js component (checkout the GitHub repo here- give it a star πŸ˜‰).

Make sure you've got the Serverless Framework CLI installed for the next part (Serverless Framework docs for reference are here ).

npm install -g serverless

In the root of your project folder add a serverless.yml file.

The following is the set up I'm currently using for a multi-staged environment.

To keep things simple I've made sure that the ENV for each stage is the same as my Amplify env and Git branch. Keeping this in mind will be helpful if you go down a multi-staged approach (highly recommended for potentially big and scaleable projects - you don't really want to push straight to production do you? πŸ€”πŸ€·β€β™‚οΈ).

stage: ${env.ENV}
myNextApp:
    component: '@sls-next/serverless-component@1.19.0-alpha.29'
    inputs:
        domain:
            - ${stage}
            - resources.example.org
        bucketName: ${stage}-resources-hub-web-files
        timeout: 30
        build:
            env:
                BUILD_ENV: ${env.ENV}
                BABEL_ENV: ${env.ENV}
            postBuildCommands: ['node serverless-post-build.js', 'node sitemap.xml.js']
    memory: 1024

To deploy your serverless infrastructure, you can simply run ENV=develop npx serverless. That was easy! Wait... for multiple staged projects (main.app.example.org / develop.app.example.org), you'll need to cache the current Serverless component state in an S3 bucket to avoid the recreation of resources.

To make things easier and to avoid some environment mishaps, I wrote a simple deploy_servereless.sh bash script which will do the necessary S3 pushing/pulling of our .serverless state to an S3 bucket (which you'll need to create and also make sure the AWS CLI is installed (not the AWS Amplify CLI)). It will also take the ENV variable from the current git branch. All you need to do then is make sure you're on the correct branch and provide a valid AWS_PROFILE.

Note: I'm using a nifty little python module called aws2-wrap that a colleague of mine wrote for the better handling of AWS credentials (specifically when using AWS SSO).

BUILD_ENV=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p')
echo "Deploying Serverless/next.js stack for ${BUILD_ENV} env...."
echo "Pulling current .serverless state from s3..."
aws2-wrap --profile $AWS_PROFILE "aws s3 sync s3://resources-hub-assets/resources-linaro-org/${BUILD_ENV}/.serverless .serverless --delete"
echo "Deploying serverless changes..."
aws2-wrap --profile $AWS_PROFILE --exec "ENV=${BUILD_ENV} npx serverless"
echo "Pushing any changes to .serverless state to s3..."
aws2-wrap --profile $AWS_PROFILE "aws s3 sync .serverless s3://resources-hub-assets/resources-linaro-org/${BUILD_ENV}/.serverless --delete"

Before running the script, make sure to give the script executable permissions:

chmod +x ./deploy_serverless.sh

Then to deploy your multi-staged Next.js hosting back-end with AWS run,

AWS_PROFILE=YourProfileName ./deploy_serverless.sh

Once your Serverless Framework stack has been deployed, you'll have a CloudFront URL to your shiny new Next.js site!

User Authentication

User authentication is always a vital part of most web apps!

Thankfully, using the AWS Amplify CLI, you can really quickly and really easily set up user authentication for your app with support for Google/Facebook/Amazon sign-in/Custom OIDC providers and more!

The "Resources Hub" I'm building required the ability for users to sign in with Google, our own SSO portal that runs Keycloak, and a standard email/password sign in.

It also required users to have unauthenticated access to our API and other resources. This is also possible with Amplify! πŸ˜… To set this up all you need to do is make sure to allow unauthenticated logins when running amplify add auth.

To get started with adding a Cognito User pool to authenticate your app users, run:

amplify add auth

There are many approaches you can take to user authentication. Amplify supports a vast majority of these, so think about how you want users to sign in and start configuring your Cognito user pool with amplify add auth. Easy, right!?

Cognito Lambda Triggers

AWS Cognito allows you to customize your user authentication flow with Lambda functions that can run at different stages of your authentication process. To add a Cognito Lambda trigger check out the AWS Amplify guide here.

With the "Resources Hub", I needed to add new users to a User table in DynamoDB when they have signed up. This is so I can manage relationships between a user and their resources (e.g comments, videos and more).

This seems like a straight forward approach if you've already added an AppSync GraphQL API. You can just fire a CreateUser mutation from within a post-confirmation lambda trigger, right?!

Not so fast! 🀚

Currently, you'll need to add the permissions to your post-confirmation lambda trigger with a custom CloudFormation template. Don't worry it's pretty straight forward with AWS Amplify.

To create a custom resource you'll need to modify amplify/backend/backend-config.json to let the Amplify CLI know about your new resources. I created a perms resource category by adding this to backend-config.json:

{
   ...
    "perms": {
        "postConfirmationUpdate": {
            "service": "Cognito Post Confirmation Trigger Updates",
            "providerPlugin": "awscloudformation",
            "dependsOn": [
                {
                    "category": "api",
                    "resourceName": "resourcesHub",
                    "attributes": ["GraphQLAPIIdOutput"]
                },
                {
                    "category": "function",
                    "resourceName": "resourceshub5749acdaPostConfirmation",
                    "attributes": ["LambdaExecutionRole"]
                }
            ]
        }
    },
    "auth": {
     ...
}

Note that in the above block for the custom perms resource, you'll need to change the dependsOn block based on your API name/post-confirmation names. You can find these in the same backend-config.json file.

So in order to add a new CloudFormation template for the postConfirmationUpdate permissions resource, you'll need to create the following folder structure:

amplify/backend/perms/postConfirmationUpdate/

Within this folder, you'll need to add your CloudFormation template and a parameters.json file for amplify to pass the necessary parameters to your template.

Important Note: if you are adding more than one template under the amplify/backend/perms/ folder (e.g amplify/backend/perms/preTokenPermissions/ and amplify/backend/perms/postConfirmationPermissions/) you'll need to make sure that your template.json file has a different name to the other files within your custom perms resource. So just make sure that your template JSON files have a naming convention of template_preTokenPermissions.json or template_postConfirmationPermissions.json. I hope this saves you some time trying to figure this out (although it's noted in the Amplify docs under adding custom resources - link to the docs). Just remember, always read the docs (RTFD)! If something is not clear in the docs then please be a good person and let the documentation owners know, if you have the timeπŸ™.

So now we've got the following files:

amplify/backend/perms/postConfirmationUpdate/template_postConfirmationUpdate.json
#and 
amplify/backend/perms/postConfirmationUpdate/parameters.json

Add this to your CloudFormation template (template_postConfirmationUpdate.json):

{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Description": "Applies AppSync Permissions to the post-confirmation lambda trigger",
    "Parameters": {
        "env": {
            "Type": "String"
        },
        "apiresourcesHubGraphQLAPIIdOutput": {
            "Type": "String",
            "Default": "apiresourcesHubGraphQLAPIIdOutput"
        },
        "functionresourceshub5749acdaPostConfirmationLambdaExecutionRole": {
            "Type": "String",
            "Default": "functionresourceshub5749acdaPostConfirmationLambdaExecutionRole"
        }
    },
    "Conditions": {},
    "Resources": {
        "PostConfirmationCognitoResourcesPolicy": {
            "Type": "AWS::IAM::Policy",
            "Properties": {
                "PolicyName": "post-confirmation-api-execution-policy",
                "Roles": [
                    {
                        "Ref": "functionresourceshub5749acdaPostConfirmationLambdaExecutionRole"
                    }
                ],
                "PolicyDocument": {
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Effect": "Allow",
                            "Action": ["ssm:*"],
                            "Resource": "*"
                        },
                        {
                            "Effect": "Allow",
                            "Action": [
                                "appsync:Create*",
                                "appsync:StartSchemaCreation",
                                "appsync:GraphQL",
                                "appsync:Get*",
                                "appsync:List*",
                                "appsync:Update*",
                                "appsync:Delete*"
                            ],
                            "Resource": [
                                {
                                    "Fn::Join": [
                                        "",
                                        [
                                            "arn:aws:appsync:",
                                            {
                                                "Ref": "AWS::Region"
                                            },
                                            ":",
                                            {
                                                "Ref": "AWS::AccountId"
                                            },
                                            ":apis/",
                                            {
                                                "Ref": "apiresourcesHubGraphQLAPIIdOutput"
                                            },
                                            "/*"
                                        ]
                                    ]
                                }
                            ]
                        }
                    ]
                }
            }
        }
    },
    "Outputs": {}
}

and a parameters.json file that looks like:

{
    "apiresourcesHubGraphQLAPIIdOutput": {
        "Fn::GetAtt": ["resourcesHub", "Outputs.GraphQLAPIIdOutput"]
    },
    "functionresourceshub5749acdaPostConfirmationLambdaExecutionRole": {
        "Fn::GetAtt": ["resourceshub5749acdaPostConfirmation", "Outputs.LambdaExecutionRole"]
    }
}

To avoid errors whilst running amplify push you'll need to make sure you follow the correct naming conventions for these variables too. Based on the above examples:

apiresourcesHubGraphQLAPIIdOutput
functionresourceshub5749acdaPostConfirmationLambdaExecutionRole
<resourceCategory><resourceName><outPutVariableName>

The same goes if you're trying to share outputs from custom resources with other custom resources!

Finally, you'll need to run amplify env checkout develop or amplify env checkout <YourAmplifyEnvName>. This will order the Amplify CLI to re-check what was added to backend-config.json and find your new resource category called perms.

Now you can run amplify push and have your Lambda execution role for your post-confirmation lambda trigger updated so that you can access your GraphQL API. πŸŽ‰

You can either, hard code your GraphQL API endpoint inside your post-confirmation function, or add another custom resource to store the GraphQL API endpoint in AWS SSM. This is due to circular dependency issues between resources (the Amplify team are looking into this btw). But for now either hardcode in the endpoint URL or add an SSM param. The CloudFormation we created above adds SSM permissions so you can just add:

amplify/backend/backend-config.json

Replace with your API name as necessary:

{
...
    "ssm": {
        "apiParams": {
            "service": "SSM-Params",
            "providerPlugin": "awscloudformation",
            "dependsOn": [
                {
                    "category": "api",
                    "resourceName": "resourcesHub",
                    "attributes": ["GraphQLAPIIdOutput", "GraphQLAPIEndpointOutput"]
                }
            ]
        }
    },
    "perms": {
...
}

amplify/backend/ssm/apiParams/apiParams_template.json

{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Description": "Creates the SSM variables for our AppSync API.",
    "Parameters": {
        "env": {
            "Type": "String"
        },
        "apiresourcesHubGraphQLAPIIdOutput": {
            "Type": "String",
            "Default": "apiresourcesHubGraphQLAPIIdOutput"
        },
        "apiresourcesHubGraphQLAPIEndpointOutput": {
            "Type": "String",
            "Default": "apiresourcesHubGraphQLAPIEndpointOutput"
        }
    },
    "Conditions": {},
    "Resources": {
        "GraphQLEndpointParam": {
            "Type": "AWS::SSM::Parameter",
            "Properties": {
                "Name": {
                    "Fn::Join": [
                        "",
                        [
                            "GraphQLAPIEndpoint-",
                            {
                                "Ref": "env"
                            }
                        ]
                    ]
                },
                "Type": "String",
                "Value": {
                    "Ref": "apiresourcesHubGraphQLAPIEndpointOutput"
                },
                "Description": "Resources Hub AppSync GraphQL API Endpoint"
            }
        },
        "GraphQLEndpointIdParam": {
            "Type": "AWS::SSM::Parameter",
            "Properties": {
                "Name": {
                    "Fn::Join": [
                        "",
                        [
                            "GraphQLAPIEndpointId-",
                            {
                                "Ref": "env"
                            }
                        ]
                    ]
                },
                "Type": "String",
                "Value": {
                    "Ref": "apiresourcesHubGraphQLAPIIdOutput"
                },
                "Description": "Resources Hub AppSync GraphQL API Endpoint Id"
            }
        }
    },
    "Outputs": {}
}

amplify/backend/ssm/apiParams/parameters.json

{
    "apiresourcesHubGraphQLAPIEndpointOutput": {
        "Fn::GetAtt": ["resourcesHub", "Outputs.GraphQLAPIEndpointOutput"]
    },
    "apiresourcesHubGraphQLAPIIdOutput": {
        "Fn::GetAtt": ["resourcesHub", "Outputs.GraphQLAPIIdOutput"]
    }
}

Remember to run amplify env checkout <envName> whenever you add new resources.

Your post-confirmation lambda code could then look something like this:

const call_to_graphql = require('/opt/helpers/graphql').call_to_graphql;
const createUser = require('/opt/graphql/mutations').createUser;
const listUsers = require('/opt/graphql/queries').listUsers;
const getSSMParam = require('/opt/helpers/ssm').getSSMParam;
/**
 * Creates a new user by calling our CreateUser Mutation within our AppSync API
 * @param {Object} event The lambda event object
 * @returns {Boolean} Returns True on successful execution of task.
 */
const createNewUserItem = async (event, gqlEndpoint) => {
    try {
        console.log(event.request.userAttributes);
        const item = {
            input: {
                name: event.request.userAttributes.name,
                email: event.request.userAttributes.email,
                status: 'ACTIVE',
            },
        };
        await call_to_graphql(item, createUser, 'CreateUser', gqlEndpoint);
        return true;
    } catch (err) {
        console.log(err);
        return false;
    }
};

/**
 * Check that a user entry does not already exist for the given email
 * If a user signs up using the form and then signs up using a third-party
 * auth provider, there would otherwise be two entries in the UserTable
 * @param {String} email The email of the user signing up.
 */
const checkUserExists = async (userEmail, gqlEndpoint) => {
    let item = {
        filter: {
            email: {
                eq: userEmail,
            },
        },
    };
    const users = await call_to_graphql(item, listUsers, 'ListUsers', gqlEndpoint);
    return users;
};
exports.handler = async (event, context, callback) => {
    console.log(event);
    const endpoint = await getSSMParam('GraphQLAPIEndpoint-' + process.env.ENV);
    const userExists = await checkUserExists(event.request.userAttributes.email, endpoint);
    console.log(userExists);
    if (userExists.data.listUsers.items.length === 0) {
        await createNewUserItem(event, endpoint);
    }
};

I created a lambda layer for common functions I'm using across the application so:

const call_to_graphql = require('/opt/helpers/graphql').call_to_graphql;
const createUser = require('/opt/graphql/mutations').createUser;
const listUsers = require('/opt/graphql/queries').listUsers;
const getSSMParam = require('/opt/helpers/ssm').getSSMParam;

actually comes from a custom lambda layer (hoorah for lambda layer support in Amplify CLI πŸŽ‰!).

Whatever you're doing in Cognito Trigger functions, make sure that your code is lightweight and executes in under 5 seconds as this is the limit for execution time! Make it short and sweet, if not make sure you trigger a secondary lambda function but don't wait for its result.

Custom Cognito Auth Domains

In order to get that "professional" look you are going to want your Cognito Auth domain to be branded. Currently, AWS Amplify doesn't support this. The team working on Amplify is tracking this as an enhancement so 🀞this get's added soon! But for now you can log in to the AWS management console and go to your Cognito User Pool to add one manually. You'll get a CloudFront URL once you've added a custom domain in the settings which you will need to add to your DNS Provider (Route53) as an A record.

Once this all setup you can override your oauth domain with your frontend project such that:

In a Next.js src/pages/_app.js file you can add something like:

...
import Amplify, { Auth } from 'aws-amplify';
import config from '../aws-exports';
...

// Set the custom auth domain if set.
if (process.env.NEXT_PUBLIC_AUTH_DOMAIN) {
    config.oauth.domain = process.env.NEXT_PUBLIC_AUTH_DOMAIN;
}
// Set the redirect URI for OAuth based on BUILD_ENV
config.oauth.redirectSignIn = process.env.NEXT_PUBLIC_REDIRECT_SIGN_IN_URL;
config.oauth.redirectSignOut = process.env.NEXT_PUBLIC_REDIRECT_SIGN_OUT_URL;
// Configure Amplify
Amplify.configure({
    ...config,
    ssr: true,
});

where the process.env variables are something like:

NEXT_PUBLIC_AUTH_DOMAIN=auth.develop.resources.example.org
NEXT_PUBLIC_REDIRECT_SIGN_IN_URL=https://develop.resources.example.org/
NEXT_PUBLIC_REDIRECT_SIGN_OUT_URL=https://develop.resources.example.org/

Auth and Unauth access to your GraphQL API

The Resources Hub I'm building isn't your typical SaaS private only application that the majority of users are building when working with AWS Amplify. The Hub needs to be able to give the public access to the API to view public videos/presentations. Luckily you can give unauthenticated users temporary credentials using:

import { Auth } from 'aws-amplify';

// Anonymous User Credential retrieval
const authenticatedUser = await Auth.currentUserCredentials();

this will give you an IAM role with unauth credentials.

Within your API schema.graphql file you can then add, for example, an @auth directive to your model like:

type Item @mode
@auth(
        rules: [
            { allow: public, operations: [read], provider: iam }
           { allow: owner, operations: [create, read, update, delete], ownerField: "itemOwner", identityClaim: "email"} 
       ]) {
// Your model attributes
itemOwner: String!
}

The above @auth rule would allow public users with an unauth IAM role to read the Item's but only owners can Create, Update, Read, Delete (CRUD), their own Item's based on the ownerField which is set to itemOwner.

Video-on-demand with amplify-video

Important Note: this plugin can incur significant costs with AWS Media Convert and more. Please only use this plugin if you're sure it's what you need!

Amplify also supports a plugin eco-system and amplify-video is a plugin that is in active development that aims to facilitate live-streaming and video-on-demand (VOD) app development. Amplify-video is a growing project so if you're interested in contributing to an awesome amplify VOD plugin - the GitHub repo is here:

github.com/awslabs/amplify-video

AWS MediaConvert is used to process the videos that you add to an input S3 bucket and then output optimized HLS streams + more in an output bucket. You can then control access to these streams with S3 or CloudFront signed URLs. AWS Media Convert can be pretty costly with the vast range of conversion options available, I tested the Apple HLS 1080p template with a 1.4GB video file from one of our conferences; which was the upper bound of file sizes. This cost around $5. I tried another example MediaConvert template (the template that was over 100 lines long!) with the same 1.4GB video that aimed to keep costs in the basic tier. This cost around $1.30. That's a lot better!

With AWS Amplify's GraphQL schema you can add field-level custom lambda function resolvers. So this enables you to return S3/CF signed URLs for your videos/presentation/digital assets. Amplify already does this for the storage category but I need more in-depth access control to assets based on very specific user-groups that are added in-app. Users that upload videos on the Resources Hub will be able to choose multiple groups (or create one) to have access to view resources.

Multi-tenant authentication

Multi-tenant auth is a necessity for many apps. AWS Cognito has recently upgraded their hard user pool limit from 500 groups to 10k. This should suit most apps and I highly recommend using Cognito groups if possible. AWS Amplify has built-in support for Cognito groups so it's pretty straight forward to add. The AWS Amplify GraphQL schema's also support group-based authentication - check it out!

Final Thoughts

So that is as much as I can write for now, but I sincerely hope this helps some developers that are just getting started with AWS Amplify; which is a great tool to speed up the development of full-stack applications.

I started using AWS Amplify around 2 years ago and I've been a part of the very active and friendly GitHub community ever since. Although there are alternative approaches out there for building full-stack applications, nothing beats the speed of development that AWS Amplify enables users to have. With the addition of support for custom resources, you can expand your infrastructure as required with CloudFormation templates in YML/JSON.

Don't get me wrong, AWS Amplify can have its teething problems but for 99% of minimum viable products that are being developed, the core CLI and eco-system that AWS Amplify offers will more than suffice your needs.

A massive thanks to Amplify/Hashnode for partnering up for this hackathon! It's providing massive value to the AWS Amplify developer community with more resources, than ever, out there and visible in search engines for us all to find and consume. As for Hashnode, I bloody love it! I've got a bunch of drafts already for blogs I've started writing simply by hitting https://hn.new - epic!

Cheers, all!

πŸŽ‰