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:
- First, you'll want to create an account.
- Then, optionally, you can install the Netlify CLI.
- 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.
- 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"
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"
}
}
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
usesrimraf
to delete thedist
folder -
build
runs an install, thenclean
(see above), and finallytsc
which will compile TypeScript files into thedist
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"
}
}
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"]
}
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
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
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
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
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
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()
In schemas.ts
, we set up the schema:
import { gql } from 'apollo-server-lambda'
export default gql`
type Query {
helloWorld: String
}
`
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
}
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
}
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'
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'
})
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
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
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 })
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
}
}
}
}
}
}
`
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]
}
`
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)
}
}
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]
}
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
}
}
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
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
- This Post's GitHub Repo
- This Post's Demo on Netlify
- How to Build an Automated Portfolio Using GitHub's GraphQL API and React
- Automate Your Portfolio with the GitHub GraphQL API
- urql docs
- GitHub GraphQL API docs
- Test queries in your browser with the GitHub GraphQL explorer
- My portfolio on GitHub
- My portfolio website
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)