DEV Community

Cover image for How to Deploy an Automated Portfolio with Netlify & Netlify's Serverless Functions
Josh Ellis
Josh Ellis

Posted on

How to Deploy an Automated Portfolio with Netlify & Netlify's Serverless Functions

This is probably the last post in my automated portfolio series. Last week, we went over how to implement urql in React to make GraphQL calls to the GitHub GraphQL API.

In this final installment, we'll go over how to actually deploy the site using Netlify, and how you can keep your API key private using Netlify "serverless" functions.

I'll be picking up from the code of last week's post. Here's what we'll be building: live demo. You can find this week's final code on the deploy branch. I won't be merging it into master so you can make a fork of the master to follow along with this post.

Huge shoutout to nomadoda on GitHub because I learned a lot from how they set their Netlify functions up in this repository.

Set Up On Netlify

I won't go over how to get setup with Netlify in depth because their docs cover everything you'll need:

  1. First, you'll want to create an account.
  2. Then, optionally, you can install the Netlify CLI.
  3. If you haven't already, create a GitHub repository for your project. To follow along, I recommend forking the repo from last week's post since that's the code I'll be using.
  4. Finally, log into your account, click "New Site From Git", and follow the prompts to get your site up and running.

If you did everything right, when you go to the site that Netlify deployed, you should see "Loading..." and if you open your console, there should be an error.

That's because you don't have an environment variable set up yet. We'll get to that later.

Set Up the Build Process

Let's switch gears now and open up the project locally. Clone it if you need to, then in the root folder of the project, create a file called netlify.toml with this code:

[build]
  command = "yarn run build"
  functions = "functions/dist/functions"
Enter fullscreen mode Exit fullscreen mode

This tells Netlify what to do to build the app and where to look for functions.

If you installed the Netlify CLI, you can run netlify dev to start up a local server and make sure everything is working. You should see the same page as you deployed with a with "Loading..." that never resolves.

Now, create a folder called functions in the root directory.

In functions, create a new package.json:

// functions/package.json
{
  "name": "functions",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "build": "npm install && npm run clean && tsc",
    "build:watch": "npm run clean && tsc-watch",
    "clean": "rimraf dist"
  },
  "author": "",
  "license": "",
  "dependencies": {
    "apollo-server-lambda": "^2.18.1",
    "axios": "^0.20.0",
  },
  "devDependencies": {
    "@types/node": "^13.11.0",
    "rimraf": "^3.0.2",
    "tsc-watch": "^4.2.3",
    "typescript": "^3.8.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating a package.json within the functions folder allows you to keep your function dependencies separate from your React dependencies.

If you're curious what each dependency is for...
- apollo-server-lambda is how we'll set up a graphql endpoint
- axios makes querying the GitHub API simple
- @types/node, tsc-watch, and typescript are for TypeScript support
- rimraf is used to clear the dist folder when building

There are also some important scripts:

  • clean uses rimraf to delete the dist folder
  • build runs an install, then clean (see above), and finally tsc which will compile TypeScript files into the dist folder

Next, we need to modify the root folder's package.json:

// package.json
{
  // ...
  "scripts": {
    // ...
    "build": "concurrently \"npm run build:web\" \"npm run build:functions\"",
    "build:functions": "cd functions && npm run build",
    "build:web": "react-scripts build",
  },
  "devDependencies": {
    // ...
    "concurrently": "^5.1.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we've changed the original build script to build:web and added a new build:functions script that runs the build in /functions.

Using concurrently in the main build script allows the process to go a bit faster since it will run both builds at the same time.

Lastly, in your .gitignore file, change /node_modules to node_modules without the slash because if you build /functions, you'll create /functions/node_modules and we don't want that folder tracked by git.

That's all the node setup done. Let's setup Typescript next.

Setting up TypeScript for Functions

We've already got all the dependencies we need setup from the last section, but we do need to make a tsconfig.json in /functions:

// functions/tsconfig.json
{
  "compilerOptions": {
    "target": "es2019",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "outDir": "./dist"
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

This is a pretty basic tsconfig, so I won't go over each option. Feel free to ask questions in the comments!

Next, we'll need functions/.gitignore:

# functions/.gitignore
dist
Enter fullscreen mode Exit fullscreen mode

If you compile the TypeScript files, it will generate JavaScript files in dist, and it's generally good to ignore generated/built files. You could instead put /functions/dist or simply dist in your root-level .gitignore if you prefer.

Your /functions should look like this right now:

 📂️ functions
 ┣ 📜 .gitignore
 ┣ 📜 tsconfig.json
 ┗ 📜 package.json
Enter fullscreen mode Exit fullscreen mode

Add a folder called src with index.ts inside. Then add a folder called functions inside src and a folder called github-graphql inside functions. In there, create three files: github-graphql.ts, schema.ts, and queries.ts:

 📂️ functions
 ┣ 📂 src
 ┃ ┗ 📜 index.ts
 ┃ ┗ 📂 functions
 ┃ ┃ ┗ 📂 github-graphql
 ┃ ┃ ┃ ┗ 📜 github-graphql.ts
 ┃ ┃ ┃ ┗ 📜 schema.ts
 ┃ ┃ ┃ ┗ 📜 queries.ts
 ┣ 📜 .gitignore
 ┗ 📜 package.json
Enter fullscreen mode Exit fullscreen mode

To be honest, nesting in a functions folder is a bit overkill here. But if we needed some shared code between functions (in a more complex app), we could put the shared code in the src folder without exposing them as functions.

Lastly, in all the .ts files, let's put a single line of useless code for now (just to make sure the build process is working):

// *.ts
export default null
Enter fullscreen mode Exit fullscreen mode

If you have empty .ts files, the compiler will complain, but now with a simple export, you should be able to run yarn install and yarn build in the root folder without errors.

How Netlify Functions Work

Let's start by talking about how Netlify functions work in the first place. Remember how in netlify.toml we set functions = "functions/dist/functions"? That tells Netlify where to look for our functions. What it will find in there is this structure from the TypeScript build:

 📂️ functions/dist/functions
 ┗ 📂 github-graphql
   ┗ 📜 github-graphql.js
   ┗ 📜 schema.js
   ┗ 📜 queries.js
Enter fullscreen mode Exit fullscreen mode

When Netlify's parser sees a folder (github-graphql) in the functions folder, it looks for either index.js or a .js file matching the folder name (github-graphql.js in our case) to use as the 'main' file.

It then expects an exported function named handler to be present in the file that it finds.

A Note on using index.js

Though it would be arguably look 'cleaner' to use index.js instead of duplicating the name of the folder, I find it's easier working in an IDE when the file has a unique name, which is why we're using github-graphql here as the file name instead of index.

GraphQL Hello World with Netlify Functions

We'll start by seeing if we can get a simple 'hello world' query running. In functions/src/functions/github-graphql/index.ts, add this code:

// functions/src/functions/github-graphql/index.ts

import { ApolloServer } from 'apollo-server-lambda'
import typeDefs from './schema'

// First we set up a GraphQL `resolvers` object that has one `Query` which returns a promise that resolves the string `'hello world'`.
const resolvers = {
  Query: {
    helloWorld: async () => 'hello world'
  }
}

// Then, we create an Apollo server that uses the `resolvers` object and imported type definitions from `schema.ts` that we haven't set up yet.
const server = new ApolloServer({ typeDefs, resolvers })

// Finally, apollo-server-lambda conveniently has a .createHandler() method that we expose as the handler of our function.
export const handler = server.createHandler()
Enter fullscreen mode Exit fullscreen mode

In schemas.ts, we set up the schema:

import { gql } from 'apollo-server-lambda'

export default gql`
  type Query {
    helloWorld: String
  }
`
Enter fullscreen mode Exit fullscreen mode

This is to tell Apollo's GraphQL server that the Query called helloWorld should return a String. Note that unlike TypeScript, GraphQL types are capitalized.

Now let's set up the frontend to see if this is working. In
our queries folder, create a new file called HelloWorld.graphql:

// src/graphql/queries/HelloWorld.graphql
query HelloWorld {
  helloWorld
}
Enter fullscreen mode Exit fullscreen mode

While there, delete the PinnedRepos.graphql file. We'll be totally remaking it soon.

Next, we need to set up a schema.gql file for codegen in the root:

// schema.gql
type Query {
  helloWorld: String
}
Enter fullscreen mode Exit fullscreen mode

Note: I'm sure there's a cleaner/DRY way of doing the schema instead of copying it in two places, but I wasn't able to figure out how to generate it automatically from the Netlify dev server and using .graphql files directly in .ts is complicated... I'll update here if I figure out a simple solution.

Now that we have a schema file, let's update codegen.yml:

overwrite: true
schema: 'schema.gql'
documents: 'src/graphql/**/*.graphql'
generates:
  src/generated/graphql.tsx:
    plugins:
      - 'typescript'
      - 'typescript-operations'
      - 'typescript-urql'
Enter fullscreen mode Exit fullscreen mode

Run yarn gen in the root to generate your urql hooks.

Then, go to App.tsx and change the client endpoint:

// src/App.tsx
const client = createClient({
  url: '/.netlify/functions/github-graphql'
})
Enter fullscreen mode Exit fullscreen mode

Note that we've removed both uses of process.env.REACT_APP_GITHUB_TOKEN from the frontend, which means our token is no longer exposed, making it more secure!

Finally, let's see if everything is working by changing <PinnedRepos /> to only use the hello world hook. Here's what the whole file should look like (yes, delete all the old code, we'll put it back in later):

// src/components/PinnedRepos.tsx
import React from 'react'
import { useHelloWorldQuery } from '../generated/graphql'

export const PinnedRepos: React.FC = () => {
  const [{ data }] = useHelloWorldQuery()
  return <>{data?.helloWorld ? data.helloWorld : null}</>
}

export default PinnedRepos
Enter fullscreen mode Exit fullscreen mode

Now run yarn build && netlify dev to build and then serve the app. You should see 'hello world' on the page. Nice!

Querying GitHub API

If you haven't already set up a GitHub token, head over to Creating a personal access token and come back with your shiny new token.

We'll put that token in a .env file in root for testing:

# .env
GH_TOKEN=tokenString
Enter fullscreen mode Exit fullscreen mode

Make sure to add .env to .gitignore if it's not already there!

Now, in the functions folder, open github-graphql.ts and let's create a functions that lets us query using axios. This is where that token is used for authorization:

// functions/src/functions/github-graphql/github-graphql.ts
import axios from 'axios'

const API_URL = 'https://api.github.com/graphql'
const headers = {
  authorization: `Bearer ${process.env.GH_TOKEN}`
}

const postAPI = async (query: string) =>
  await axios.post(API_URL, { query }, { headers })
Enter fullscreen mode Exit fullscreen mode

If you didn't know, GraphQL isn't magic. A GraphQL query is just a POST request with a query object, so we're just going to keep it simple and use POST to do our backend queries.

We haven't created the query yet. Let's do that now in queries.ts:

// functions/src/functions/github-graphql/queries.ts

export const PinnedReposQuery = `
  query PinnedRepos {
    viewer {
      pinnedItems(first: 3) {
        edges {
          node {
            ... on Repository {
              name
              description
              pushedAt
              url
              homepageUrl
            }
          }
        }
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Now, we need to update our schema.ts:

// functions/src/functions/github-graphql/schema.ts

export default gql`
  type Repository {
    name: String
    description: String
    pushedAt: String
    url: String
    homepageUrl: String
  }

  type Query {
    helloWorld: String
    pinnedRepos: [Repository]
  }
`
Enter fullscreen mode Exit fullscreen mode

Other than security, another nice thing about wrapping the API ourselves is we can restructure the data to make it cleaner to work with on the frontend.

So we'll be returning an array of a simplified Repository type rather than the deeply nested object that GitHub's API gives us. This will make more sense when you see the resolver.

Back to github-graphql.ts, we can update our resolvers and import the query:

// functions/src/functions/github-graphql/github-graphql.ts
import { PinnedReposQuery } from '/queries'

const resolvers = {
  Query: {
    helloWorld: async () => 'hello world',
    pinnedRepos: async () =>
      (await postAPI(PinnedReposQuery)).data.data.viewer.pinnedItems.edges.map((edge: any) => edge.node)
  }
}
Enter fullscreen mode Exit fullscreen mode

So as you can see, we're grabbing the deeply nested data and returning a much simpler array. Let's finish up on the frontend.

Displaying the Pinned Repos

First, we need to update our schema.gql:

type Repository {
  name: String
  description: String
  pushedAt: String
  url: String
  homepageUrl: String
}

type Query {
  helloWorld: String
  pinnedRepos: [Repository]
}
Enter fullscreen mode Exit fullscreen mode

Now, we need to add the query file to the frontend. Add PinnedRepos.graphql to queries:

// src/graphql/queries/PinnedRepos.graphql
query PinnedRepos {
  pinnedRepos {
    name
    description
    pushedAt
    url
    homepageUrl
  }
}
Enter fullscreen mode Exit fullscreen mode

Run yarn gen to create the hook for urql, then head over to PinnedRepos.tsx to use that query instead of the hello world one and add some basic rendering from before:

// src/components/PinnedRepos.tsx
import React from 'react'
import { usePinnedReposQuery } from '../generated/graphql'

export const PinnedRepos: React.FC = () => {
  const [{ data }] = usePinnedReposQuery()
  return (
    <>
      {data?.pinnedRepos ? (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            justifyContent: 'center',
            textAlign: 'left'
          }}
        >
          {data.pinnedRepos.map((repo, index) => {
            if (repo) {
              const { name, description, url, homepageUrl, pushedAt } = {
                name: '',
                description: '',
                url: '',
                homepageUrl: '',
                pushedAt: '',
                ...repo
              }
              return (
                <div
                  key={index}
                  style={{ marginLeft: '1rem', maxWidth: '24rem' }}
                >
                  <h2>{name}</h2>
                  {pushedAt ? <p>updated: {pushedAt}</p> : null}
                  <h4 style={{ marginBottom: 0 }}>Description</h4>
                  <p style={{ marginTop: 0 }}>
                    {description ? description : 'no description'}
                  </p>
                  {url ? <a href={url}>View on GitHub</a> : null}
                  {homepageUrl ? (
                    <a href={homepageUrl} style={{ marginLeft: '1rem' }}>
                      View website
                    </a>
                  ) : null}
                </div>
              )
            } else {
              return null
            }
          })}
        </div>
      ) : (
        <p>Loading...</p>
      )}
    </>
  )
}

export default PinnedRepos
Enter fullscreen mode Exit fullscreen mode

Now you can run yarn build && netlify dev.

🎉 Everything should be working! If so, congrats, you did it! If not, feel free to drop questions in the comments (or compare to my deploy branch code).

Deploying to Netlify

Next, we need to add the GH_TOKEN to your deploy environment variables on Netlify:

In your site dashboard under Settings > Build & deploy > Environment > Environment variables.

Once you add that on Netlify, and have everything working locally, you can push your changes to your repository's master branch on GitHub. Netlify will automatically build and deploy the new site.

Resources

Let's Talk

If you have any questions, leave a comment, and I'll do my best to answer it! Also, I'm still learning GraphQL, so please let me know if I included any misinformation.

Thanks for reading!

Top comments (0)