In other words, lots to learn! By the end, you’ll not only get to dive into Jamstack and serverless concepts, but also hands-on experience with a really neat combination of tech that I think you’ll really like.
Creating a Redwood app
Redwood is a framework for serverless applications that pulls together React (for front-end component), GraphQL (for data) and Prisma (for database queries).
There are other front-end frameworks that we could use here. One example is Bison, created by Chris Ball. It leverages GraphQL in a similar fashion to Redwood, but uses a slightly different lineup of GraphQL libraries, such as Nexus in place of Apollo Client and GraphQL Codegen, in place of the Redwood CLI. But it’s only been around a few months, so the project is still very new compared to Redwood, which has been in development since June 2019.
There are many great Redwood starter templates we could use to bootstrap our application, but I want to start by generating a Redwood boilerplate project and looking at the different pieces that make up a Redwood app. We’ll then build up the project, piece by piece.
We will need to install Yarn to use the Redwood CLI to get going. Once that’s good to go, here’s what to run in a terminal
yarn create redwood-app ./csstricks
We’ll now cd
into our new project directory and start our development server.
cd csstricks
yarn rw dev
Our project’s front-end is now running on localhost:8910
. Our back-end is running on localhost:8911
and ready to receive GraphQL queries. By default, Redwood comes with a GraphiQL playground that we’ll use towards the end of the article.
Let’s head over to localhost:8910
in the browser. If all is good, the Redwood landing page should load up.
Redwood is currently at version 0.21.0, as of this writing. The docs warn against using it in production until it officially reaches 1.0. They also have a community forum where they welcome feedback and input from developers like yourself.
Directory structure
Redwood values convention over configuration and makes a lot of decisions for us, including the choice of technologies, how files are organized, and even naming conventions. This can result in an overwhelming amount of generated boilerplate code that is hard to comprehend, especially if you’re just digging into this for the first time.
Here’s how the project is structured:
├── api
│ ├── prisma
│ │ ├── schema.prisma
│ │ └── seeds.js
│ └── src
│ ├── functions
│ │ └── graphql.js
│ ├── graphql
│ ├── lib
│ │ └── db.js
│ └── services
└── web ├── public │ ├── favicon.png │ ├── README.md │ └── robots.txt └── src ├── components ├── layouts ├── pages │ ├── FatalErrorPage │ │ └── FatalErrorPage.js │ └── NotFoundPage │ └── NotFoundPage.js ├── index.css ├── index.html ├── index.js └── Routes.js
Don’t worry too much about what all this means yet; the first thing to notice is things are split into two main directories: web
and api
. Yarn workspaces allows each side to have its own path in the codebase.
web
contains our front-end code for:
- Pages
- Layouts
- Components
api
contains our back-end code for:
- Function handlers
- Schema definition language
- Services for back-end business logic
- Database client
Redwood assumes Prisma as a data store, but we’re going to use Fauna instead. Why Fauna when we could just as easily use Firebase? Well, it’s just a personal preference. After Google purchased Firebase they launched a real-time document database, Cloud Firestore, as the successor to the original Firebase Realtime Database. By integrating with the larger Firebase ecosystem, we could have access to a wider range of features than what Fauna offers. At the same time, there are even a handful of community projects that have experimented with Firestore and GraphQL but there isn’t first class GraphQL support from Google.
Since we will be querying Fauna directly, we can delete the prisma
directory and everything in it. We can also delete all the code in db.js
. Just don’t delete the file as we’ll be using it to connect to the Fauna client.
index.html
We’ll start by taking a look at the web
side since it should look familiar to developers with experience using React or other single-page application frameworks.
But what actually happens when we build a React app? It takes the entire site and shoves it all into one big ball of JavaScript inside index.js
, then shoves that ball of JavaScript into the “root” DOM node, which is on line 11 of index.html
.
<!DOCTYPE html>
<html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/png" href="/favicon.png" /> <title><%= htmlWebpackPlugin.options.title %></title> </head> <body> <div id="redwood-app"></div> // HIGHLIGHT </body>
</html>
While Redwood uses Jamstack in the documentation and marketing of itself, Redwood doesn’t do pre-rendering yet (like Next or Gatsby can), but is still Jamstack in that it’s shipping static files and hitting APIs with JavaScript for data.
index.js
index.js
contains our root component (that big ball of JavaScript) that is rendered to the root DOM node. document.getElementById()
selects an element with an id
containing redwood-app
, and ReactDOM.render()
renders our application into the root DOM element.
RedwoodProvider
The <Routes />
component (and by extension all the application pages) are contained within the <RedwoodProvider>
tags. Flash uses the Context API for passing message objects between deeply nested components. It provides a typical message display unit for rendering the messages provided to FlashContext.
FlashContext’s provider component is packaged with the <RedwoodProvider />
component so it’s ready to use out of the box. Components pass message objects by subscribing to it (think, “send and receive”) via the provided useFlash hook.
FatalErrorBoundary
The provider itself is then contained within the <FatalErrorBoundary>
component which is taking in <FatalErrorPage>
as a prop. This defaults your website to an error page when all else fails.
import ReactDOM from 'react-dom'
import { RedwoodProvider, FatalErrorBoundary } from '@redwoodjs/web'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes'
import './index.css' ReactDOM.render( <FatalErrorBoundary page={FatalErrorPage}> <RedwoodProvider> <Routes /> </RedwoodProvider> </FatalErrorBoundary>, document.getElementById('redwood-app')
)
Routes.js
Router
contains all of our routes and each route is specified with a Route
. The Redwood Router attempts to match the current URL to each route, stopping when it finds a match and then renders only that route. The only exception is the notfound
route which renders a single Route
with a notfound
prop when no other route matches.
import { Router, Route } from '@redwoodjs/router' const Routes = () => { return ( <Router> <Route notfound page={NotFoundPage} /> </Router> )
} export default Routes
Pages
Now that our application is set up, let’s start creating pages! We’ll use the Redwood CLI generate page
command to create a named route function called home
. This renders the HomePage
component when it matches the URL path to /
.
We can also use rw
instead of redwood
and g
instead of generate
to save some typing.
yarn rw g page home /
This command performs four separate actions:
- It creates
web/src/pages/HomePage/HomePage.js
. The name specified in the first argument gets capitalized and “Page” is appended to the end. - It creates a test file at
web/src/pages/HomePage/HomePage.test.js
with a single, passing test so you can pretend you’re doing test-driven development. - It creates a Storybook file at
web/src/pages/HomePage/HomePage.stories.js
. - It adds a new
<Route>
inweb/src/Routes.js
that maps the/
path to theHomePage
component.
HomePage
If we go to web/src/pages
we’ll see a HomePage
directory containing a HomePage.js
file. Here’s what’s in it:
// web/src/pages/HomePage/HomePage.js import { Link, routes } from '@redwoodjs/router' const HomePage = () => { return ( <> <h1>HomePage</h1> <p> Find me in <code>./web/src/pages/HomePage/HomePage.js</code> </p> <p> My default route is named <code>home</code>, link to me with ` <Link to={routes.home()}>Home</Link>` </p> </> )
} export default HomePage
We’re going to move our page navigation into a re-usable layout component which means we can delete the Link
and routes
imports as well as <Link to={routes.home()}>Home</Link>
. This is what we’re left with:
// web/src/pages/HomePage/HomePage.js const HomePage = () => { return ( <> <h1>RedwoodJS+FaunaDB+Vercel 🚀</h1> <p>Taking Fullstack to the Jamstack</p> </> )
} export default HomePage
AboutPage
To create our AboutPage
, we’ll enter almost the exact same command we just did, but with about
instead of home
. We also don’t need to specify the path since it’s the same as the name of our route. In this case, the name and path will both be set to about
.
yarn rw g page about
// web/src/pages/AboutPage/AboutPage.js import { Link, routes } from '@redwoodjs/router' const AboutPage = () => { return ( <> <h1>AboutPage</h1> <p> Find me in <code>./web/src/pages/AboutPage/AboutPage.js</code> </p> <p> My default route is named <code>about</code>, link to me with ` <Link to={routes.about()}>About</Link>` </p> </> )
} export default AboutPage
We’ll make a few edits to the About page like we did with our Home page. That includes taking out the <Link>
and routes
imports and deleting Link to={routes.about()}>About</Link>
.
Here’s the end result:
// web/src/pages/AboutPage/AboutPage.js const AboutPage = () => { return ( <> <h1>About 🚀🚀</h1> <p>For those who want to stack their Jam, fully</p> </> )
}
If we return to Routes.js
we’ll see our new routes for home
and about
. Pretty nice that Redwood does this for us!
const Routes = () => { return ( <Router> <Route path="/about" page={AboutPage} name="about" /> <Route path="/" page={HomePage} name="home" /> <Route notfound page={NotFoundPage} /> </Router> )
}
Layouts
Now we want to create a header with navigation links that we can easily import into our different pages. We want to use a layout so we can add navigation to as many pages as we want by importing the component instead of having to write the code for it on every single page.
BlogLayout
You may now be wondering, “is there a generator for layouts?” The answer to that is… of course! The command is almost identical as what we’ve been doing so far, except with rw g layout
followed by the name of the layout, instead of rw g page
followed by the name and path of the route.
yarn rw g layout blog
// web/src/layouts/BlogLayout/BlogLayout.js const BlogLayout = ({ children }) => { return <>{children}</>
} export default BlogLayout
To create links between different pages we’ll need to:
- Import
Link
androutes
from@redwoodjs/router
intoBlogLayout.js
- Create a
<Link to={}></Link>
component for each link - Pass a named route function, such as
routes.home()
, into theto={}
prop for each route
// web/src/layouts/BlogLayout/BlogLayout.js import { Link, routes } from '@redwoodjs/router' const BlogLayout = ({ children }) => { return ( <> <header> <h1>RedwoodJS+FaunaDB+Vercel 🚀</h1> <nav> <ul> <li> <Link to={routes.home()}>Home</Link> </li> <li> <Link to={routes.about()}>About</Link> </li> </ul> </nav> </header> <main> <p>{children}</p> </main> </> )
} export default BlogLayout
We won’t see anything different in the browser yet. We created the BlogLayout
but have not imported it into any pages. So let’s import BlogLayout
into HomePage
and wrap the entire return
statement with the BlogLayout
tags.
// web/src/pages/HomePage/HomePage.js import BlogLayout from 'src/layouts/BlogLayout' const HomePage = () => { return ( <BlogLayout> <p>Taking Fullstack to the Jamstack</p> </BlogLayout> )
} export default HomePage
If we click the link to the About page we’ll be taken there but we are unable to get back to the previous page because we haven’t imported BlogLayout
into AboutPage
yet. Let’s do that now:
// web/src/pages/AboutPage/AboutPage.js import BlogLayout from 'src/layouts/BlogLayout' const AboutPage = () => { return ( <BlogLayout> <p>For those who want to stack their Jam, fully</p> </BlogLayout> )
} export default AboutPage
Now we can navigate back and forth between the pages by clicking the navigation links! Next up, we’ll now create our GraphQL schema so we can start working with data.
Fauna schema definition language
To make this work, we need to create a new file called sdl.gql
and enter the following schema into the file. Fauna will take this schema and make a few transformations.
// sdl.gql type Post { title: String! body: String!
} type Query { posts: [Post]
}
Save the file and upload it to Fauna’s GraphQL Playground. Note that, at this point, you will need a Fauna account to continue. There’s a free tier that works just fine for what we’re doing.
It’s very important that Redwood and Fauna agree on the SDL, so we cannot use the original SDL that was entered into Fauna because that is no longer an accurate representation of the types as they exist on our Fauna database.
The Post
collection and posts Index
will appear unaltered if we run the default queries in the shell, but Fauna creates an intermediary PostPage
type which has a data
object.
Redwood schema definition language
This data
object contains an array with all the Post
objects in the database. We will use these types to create another schema definition language that lives inside our graphql
directory on the api
side of our Redwood project.
// api/src/graphql/posts.sdl.js import gql from 'graphql-tag' export const schema = gql` type Post { title: String! body: String! } type PostPage { data: [Post] } type Query { posts: PostPage }
`
Services
The posts
service sends a query to the Fauna GraphQL API. This query is requesting an array of posts, specifically the title
and body
for each. These are contained in the data
object from PostPage
.
// api/src/services/posts/posts.js import { request } from 'src/lib/db'
import { gql } from 'graphql-request' export const posts = async () => { const query = gql` { posts { data { title body } } } ` const data = await request(query, 'https://graphql.fauna.com/graphql') return data['posts']
}
At this point, we can install graphql-request
, a minimal client for GraphQL with a promise-based API that can be used to send GraphQL requests:
cd api
yarn add graphql-request graphql
Attach the Fauna authorization token to the request header
So far, we have GraphQL for data, Fauna for modeling that data, and graphql-request
to query it. Now we need to establish a connection between graphql-request
and Fauna, which we’ll do by importing graphql-request
into db.js
and use it to query an endpoint
that is set to https://graphql.fauna.com/graphql
.
// api/src/lib/db.js import { GraphQLClient } from 'graphql-request' export const request = async (query = {}) => { const endpoint = 'https://graphql.fauna.com/graphql' const graphQLClient = new GraphQLClient(endpoint, { headers: { authorization: 'Bearer ' + process.env.FAUNADB_SECRET }, }) try { return await graphQLClient.request(query) } catch (error) { console.log(error) return error }
}
A GraphQLClient
is instantiated to set the header with an authorization token, allowing data to flow to our app.
Create
We’ll use the Fauna Shell and run a couple of Fauna Query Language (FQL) commands to seed the database. First, we’ll create a blog post with a title
and body
.
Create( Collection("Post"), { data: { title: "Deno is a secure runtime for JavaScript and TypeScript.", body: "The original creator of Node, Ryan Dahl, wanted to build a modern, server-side JavaScript framework that incorporates the knowledge he gained building out the initial Node ecosystem." } }
)
{ ref: Ref(Collection("Post"), "282083736060690956"), ts: 1605274864200000, data: { title: "Deno is a secure runtime for JavaScript and TypeScript.", body: "The original creator of Node, Ryan Dahl, wanted to build a modern, server-side JavaScript framework that incorporates the knowledge he gained building out the initial Node ecosystem." }
}
Let’s create another one.
Create( Collection("Post"), { data: { title: "NextJS is a React framework for building production grade applications that scale.", body: "To build a complete web application with React from scratch, there are many important details you need to consider such as: bundling, compilation, code splitting, static pre-rendering, server-side rendering, and client-side rendering." } }
)
{ ref: Ref(Collection("Post"), "282083760102441484"), ts: 1605274887090000, data: { title: "NextJS is a React framework for building production grade applications that scale.", body: "To build a complete web application with React from scratch, there are many important details you need to consider such as: bundling, compilation, code splitting, static pre-rendering, server-side rendering, and client-side rendering." }
}
And maybe one more just to fill things up.
Create( Collection("Post"), { data: { title: "Vue.js is an open-source front end JavaScript framework for building user interfaces and single-page applications.", body: "Evan You wanted to build a framework that combined many of the things he loved about Angular and Meteor but in a way that would produce something novel. As React rose to prominence, Vue carefully observed and incorporated many lessons from React without ever losing sight of their own unique value prop." } }
)
{ ref: Ref(Collection("Post"), "282083792286384652"), ts: 1605274917780000, data: { title: "Vue.js is an open-source front end JavaScript framework for building user interfaces and single-page applications.", body: "Evan You wanted to build a framework that combined many of the things he loved about Angular and Meteor but in a way that would produce something novel. As React rose to prominence, Vue carefully observed and incorporated many lessons from React without ever losing sight of their own unique value prop." }
}
Cells
Cells provide a simple and declarative approach to data fetching. They contain the GraphQL query along with loading, empty, error, and success states. Each one renders itself automatically depending on what state the cell is in.
BlogPostsCell
yarn rw generate cell BlogPosts export const QUERY = gql` query BlogPostsQuery { blogPosts { id } }
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }) => <div>Error: {error.message}</div> export const Success = ({ blogPosts }) => { return JSON.stringify(blogPosts)
}
By default we have the query render the data with JSON.stringify
on the page where the cell is imported. We’ll make a handful of changes to make the query and render the data we need. So, let’s:
- Change
blogPosts
toposts
. - Change
BlogPostsQuery
toPOSTS
. - Change the query itself to return the
title
andbody
of each post. - Map over the
data
object in the success component. - Create a component with the
title
andbody
of theposts
returned through thedata
object.
Here’s how that looks:
// web/src/components/BlogPostsCell/BlogPostsCell.js export const QUERY = gql` query POSTS { posts { data { title body } } }
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }) => <div>Error: {error.message}</div> export const Success = ({ posts }) => { const {data} = posts return data.map(post => ( <> <header> <h2>{post.title}</h2> </header> <p>{post.body}</p> </> ))
}
The POSTS
query is sending a query for posts
, and when it’s queried, we get back a data
object containing an array of posts. We need to pull out the data
object so we can loop over it and get the actual posts. We do this with object destructuring to get the data
object and then we use the map()
function to map over the data
object and pull out each post. The title
of each post is rendered with an <h2>
inside <header>
and the body is rendered with a <p>
tag.
Import BlogPostsCell to HomePage
// web/src/pages/HomePage/HomePage.js import BlogLayout from 'src/layouts/BlogLayout'
import BlogPostsCell from 'src/components/BlogPostsCell/BlogPostsCell.js' const HomePage = () => { return ( <BlogLayout> <p>Taking Fullstack to the Jamstack</p> <BlogPostsCell /> </BlogLayout> )
} export default HomePage
Vercel
We do mention Vercel in the title of this post, and we’re finally at the point where we need it. Specifically, we’re using it to build the project and deploy it to Vercel’s hosted platform, which offers build previews when code it pushed to the project repository. So, if you don’t already have one, grab a Vercel account. Again, the free pricing tier works just fine for this work.
Why Vercel over, say, Netlify? It’s a good question. Redwood even began with Netlify as its original deploy target. Redwood still has many well-documented Netlify integrations. Despite the tight integration with Netlify, Redwood seeks to be universally portable to as many deploy targets as possible. This now includes official support for Vercel along with community integrations for the Serverless framework, AWS Fargate, and PM2. So, yes, we could use Netlify here, but it’s nice that we have a choice of available services.
We only have to make one change to the project’s configuration to integrate it with Vercel. Let’s open netlify.toml
and change the apiProxyPath
to "/api"
. Then, let’s log into Vercel and click the “Import Project” button to connect its service to the project repository. This is where we enter the URL of the repo so Vercel can watch it, then trigger a build and deploy when it noticed changes.
Redwood has a preset build command that works out of the box in Vercel:
We’re pretty far along, but even though the site is now “live” the database isn’t connected:
To fix that, we’ll add the FAUNADB_SECRET
token from our Fauna account to our environment variables in Vercel:
Now our application is complete!
We did it! I hope this not only gets you super excited about working with Jamstack and serverless, but got a taste of some new technologies in the process.
The post Deploying a Serverless Jamstack Site with RedwoodJS, Fauna, and Vercel appeared first on CSS-Tricks.
You can support CSS-Tricks by being an MVP Supporter.