Many modern approaches for designing flexible and scalable web applications have become popular as browsers have evolved. Jamstack is one of these cutting-edge techniques to develop lightning-fast web apps. Jamstack combines the capabilities of JavaScript, APIs, and Markup to create modern, browser-friendly web apps, which can function without any web servers. In this tutorial, you'll create a custom scrapbook application that allows you to store and access your daily memories from anywhere in the world.
Check out the live demo of the application you'll create.
Before diving into this tutorial:
- You should have a basic understanding of how to use ES6 features of JavaScript.
- You should have beginner-level experience of using React and React Hooks.
- It would be advantageous if you have prior expertise with any design system in React.
Tech Stack
Jamstack is unique because it allows you to integrate several technologies to create a full-stack application. You will use the following technologies to create this scrapbook:
- Next.js is a React framework that extends the amazing powers of React for creating multiple page apps easily. Without using any backend frameworks like Express, you may use Next.js and its serverless functions to develop your app's frontend and backend.
- Chakra-UI provides a plethora of stylish and accessible react components for styling your web application.
- You'll be using Airtable as a database solution for this application. Airtable is a spreadsheet/database hybrid with a fantastic API for integrating it into your application.
- Cloudinary is a cloud media management platform where you'll upload photos of your scrapbook.
- Auth0 enables you to integrate a user authentication system into your app. It uses OAuth 2.0 and provides a secure OAuth layer for your app.
Auth0 and Cloudinary both offer free plans. You can create an account and use it for free to develop this application.
Table of Contents
- Getting Started
- Connecting Airtable to your app
- Integrate Airtable with Next.js Serverless functions
- Uploading files to Cloudinary
- Create React Context for Posts
- Setup Authentication with Auth0
- Next Steps
Getting Started
Fork this starter code sandbox template and get ready for coding the scrapbook application. If you prefer to use local development, you should have Node and NPM installed.
Running the following command creates a Next.js and Chakra-UI starter application with no configuration.
npx create-next-app --example with-chakra-ui <app-name>
# or
yarn create next-app --example with-chakra-ui <app-name>
Now, head over to Airtable and create a free account for yourself or log in if you already have one. After logging in, create a new base(database) from scratch by clicking on Add a base
button and give it a meaningful name. It makes a new base with some primary fields. You can customize the table by double-clicking on the columns. Start customizing the table name to posts
and add the following columns:
- image - URL
- caption - single-line text
- cloudinaryId - single-line text
- userId - single-line text
- date - Date
The base should look something like this:
Next, navigate to Airtable API and select the base you'd like to integrate. Create a .env
file in the root directory and add some secrets as environment variables. For connecting Airtable to our app, you'll need the following secrets in the code.
-
API_KEY
: the Airtable API key. You can find it within the documentation(by checking the "Show API key" box in the top right) or on your account page. -
BASE_ID
: the id of the base you want to integrate. You can find it on the documentation page. -
TABLE_NAME
: the name of the table in that base (you can use a single base for multiple tables).
/.env
AIRTABLE_API_KEY=<your-airtable-api-key>
AIRTABLE_BASE_ID=<your-airtable-base-id>
AIRTABLE_TABLE_NAME=<your-airtable-table-name>
Install the Airtable JavaScript SDK to work with Airtable inside a Next.js application.
npm install airtable
Also, install the following dependencies to finish setting up your dev environment.
npm install @auth0/nextjs-auth0 multer next-connect cloudinary
For using Auth0 and Cloudinary in your application, you need Auth0 Next.js SDK (@auth0/nextjs-auth0
) and Cloudinary SDK (cloudinary
) respectively. Multer is for handling file inputs and Next-connect is for dealing with middleware in Next.js API Routes.
Connecting Airtable to Your App
Now, create a new folder /utils
inside the /src
folder and add a new file Airtable.js
. The code below connects your app to Airtable, retrieves the data. By default, the Airtable returns unnecessary data. The minifyRecords
function returns the minified version of the record with necessary data.
/utils/Airtable.js
const Airtable = require('airtable')
// Authenticate
Airtable.configure({
apiKey: process.env.AIRTABLE_API_KEY
})
// Initialize a base
const base = Airtable.base(process.env.AIRTABLE_BASE_ID)
// Reference a table
const table = base(process.env.AIRTABLE_TABLE_NAME)
// To get an array of meaningful records
const minifyRecords = (records) =>
records.map((record) => ({
id: record.id,
fields: record.fields
}))
export { table, minifyRecords }
Integrate Airtable with Next.js Serverless Functions
Using API routes, you can construct your own API in Next.js. Any file you add inside the /pages/api
folder will be treated as an API endpoint(/api/*
) rather than a regular route. You can handle any request that hits the endpoint using serverless functions. Let's create an API endpoint to retrieve Airtable records.
/src/pages/api/getPosts.js
// For retreving posts from Airtable
import { table, minifyRecords } from '../../utils/Airtable'
export default async (req, res) => {
try {
// get records from airtable
const records = await table.select({}).firstPage()
// send an array of minified records as a response
const minfiedRecords = minifyRecords(records)
res.status(200).json(minfiedRecords)
} catch (error) {
console.error(error)
res.status(500).json({ msg: 'Something went wrong! 😕' })
}
}
Setting up Cloudinary
After uploading the scrapbook photos to Cloudinary, you will need to store the secure_url
and public_id
to the Airtable database. Go to your Cloudinary Dashboard, copy the following secrets and paste them into the .env
file.
/.env
...
CLOUDINARY_CLOUD_NAME=<your-cloudinary-cloud-name>
CLOUDINARY_API_KEY=<your-cloudinary-api-key>
CLOUDINARY_API_SECRET=<your-cloudinary-api-secret>
After adding the environment variables, create a new file cloudinary.js
inside /utils
directory to setup cloudinary.
/utils/cloudinary.js
import { v2 as cloudinary } from 'cloudinary'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET
})
export default cloudinary
Uploading Files to Cloudinary
You can upload photos to Cloudinary using the uploader.upload
method of Cloudinary SDK. By default, Next.js API routes cannot handle file input. So you're going to use the multer
, that will append files to the request. You'll also use next-connect
to deal with the middleware functions(Learn more). Create a new file, createPost.js
inside /api
for uploading images.
/src/pages/api/createPost.js
// For creating a new record in Airtable
import nextConnect from 'next-connect'
import multer from 'multer'
import path from 'path'
import { table, minifyRecords } from '../../utils/Airtable'
import cloudinary from '../../utils/cloudinary'
// multer config
const upload = multer({
storage: multer.diskStorage({}),
fileFilter: (req, file, cb) => {
let ext = path.extname(file.originalname)
if (ext !== '.jpg' && ext !== '.jpeg' && ext !== '.png') {
cb(new Error('File type is not supported'), false)
return
}
cb(null, true)
}
})
const createPost = nextConnect({
onError(error, req, res) {
res.status(501).json({ error: error.message })
}
})
// Adds the middleware to Next-Connect
// this should be the name of the form field
createPost.use(upload.single('image'))
createPost.post(async (req, res) => {
// Uploading file to cloudinary
const result = await cloudinary.uploader.upload(req.file.path)
// Create a new record with required fields
const post = {
image: result.secure_url,
caption: req.body.caption,
cloudinaryId: result.public_id,
userId: req.body.userId
}
// Create a record with the above fields in Airtable
// the 'create' method accepts and returns an array of records
const newRecords = await table.create([{ fields: post }])
res.status(200).json(minifyRecords(newRecords)[0])
})
export default createPost
export const config = {
api: {
bodyParser: false
}
}
Try using Postman or something similar to test these endpoints. If you run into any problems, compare with the code snippet provided or try troubleshooting using the internet.
Displaying Scrapbook Posts
Now that you have the API, let's design an interface to display the data in our Next.js application. You can use the getServerSideProps
function of Next.js with serverside rendering to display data that is coming from an API. Every time the page is rendered, Next.js runs the code contained within this function.
You can learn more about Next.js server-side rendering here.
Add the following code to the index.js
file. The posts will be displayed as cards in a grid.
/src/pages/index.js
import { Container } from '../components/Container'
import { Flex, Grid, Text } from '@chakra-ui/react'
import Card from '../components/Card'
export default function Index({ initialPosts }) {
return (
<Container minH="100vh">
<Flex flexDirection="column" justifyContent="center" alignItems="center">
<Flex w="100%" flexDirection="column" my={8}>
{!initialPosts.length ? (
<Flex
h={['30vh', '50vh']}
w="100%"
justifyContent="center"
alignItems="center"
>
<Text fontSize={['2xl', '3xl']} opacity="0.2">
No Posts Added
</Text>
</Flex>
) : (
<Grid
templateColumns={[
'repeat(1, 1fr)',
'repeat(2, 1fr)',
'repeat(3, 1fr)'
]}
gap={6}
m="0 auto"
w={['100%', '90%', '85%']}
>
{initialPosts.map((post) => {
return <Card post={post.fields} key={post.id} />
})}
</Grid>
)}
</Flex>
</Flex>
</Container>
)
}
export async function getServerSideProps(context) {
try {
const res = await fetch('http://localhost:3000/api/getPosts')
return {
props: {
initialPosts: await res.json()
}
}
} catch (error) {
console.log(error)
return {
props: {
err: 'Something went wrong 😕'
}
}
}
}
/src/components/Card.js
import { Box, Text, useColorModeValue } from '@chakra-ui/react'
const Card = ({ post }) =>
post ? (
<Box
w="100%"
p={4}
flex="1"
borderWidth="1px"
bg={useColorModeValue('white', 'gray.800')}
borderColor={useColorModeValue('gray.100', 'gray.700')}
rounded="lg"
shadow="md"
>
<Text textAlign="right" fontSize="sm" fontWeight="bold" mb={4}>
{new Date(post.date).toString().substr(0, 15)}
</Text>
<a href={post.image} target="_blank" rel="noopener noreferrer">
<img src={post.image} alt={post.cloudinaryId} loading="lazy" />
</a>
<Text fontSize="md" my={4} noOfLines={[3, 4, 5]} isTruncated>
{post.caption}
</Text>
</Box>
) : null
export default Card
Create React Context for Posts
Create a React context for Posts to share the state of posts across other components. Create a new context
folder inside /src
and add a new file posts.js
.
/src/context/posts.js
import { createContext, useState } from 'react'
const PostsContext = createContext()
const PostsProvider = ({ children }) => {
const [posts, setPosts] = useState([])
const addPost = async (formData) => {
try {
// sending the form data
const res = await fetch('/api/createPost', {
method: 'POST',
body: formData
})
const newPost = await res.json()
// updating the posts state
setPosts((prevPosts) => [newPost, ...prevPosts])
} catch (error) {
console.error(error)
}
}
return (
<PostsContext.Provider
value={{
posts,
setPosts,
addPost
}}
>
{children}
</PostsContext.Provider>
)
}
export { PostsContext, PostsProvider }
Wrap the app around the PostsProvider
to use this context in your application.
/src/pages/_app.js
import { PostsProvider } from '../context/posts'
import theme from '../utils/theme'
function MyApp({ Component, pageProps }) {
return (
<PostsProvider>
<ChakraProvider resetCSS theme={theme}>
...
</ChakraProvider>
</PostsProvider>
)
}
export default MyApp
Now, update the posts
state to the initialPosts
inside the index.js
file. At this point, you can see the cards populated with data from Airtable.
/src/pages/index.js
import { useContext, useEffect } from 'react'
import { PostsContext } from '../context/posts'
export default function Index({ initialPosts }) {
const { posts, setPosts } = useContext(PostsContext)
useEffect(() => {
setPosts(initialPosts)
}, [initialPosts, setPosts])
...
}
Creating a Form to Add Post
Create a form
to add posts from a webpage. Import the addPost
function you created in context to submit the form data. Before uploading the file, you should include a preview of the uploaded photo. See more about handling file input in JavaScript. The toast in Chakra-UI is something that displays a message in a separate modal. In this component, you'll use a toast to show the success message.
/src/components/AddPost.js
import { useContext, useRef, useState } from 'react'
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
FormControl,
FormLabel,
Input,
useDisclosure,
useToast
} from '@chakra-ui/react'
import { PostsContext } from '../context/posts'
export const AddPost = ({ children }) => {
const [image, setImage] = useState()
const [caption, setCaption] = useState('')
const [previewSource, setPreviewSource] = useState('')
const [fileInputState, setFileInputState] = useState('')
const { addPost } = useContext(PostsContext)
const { isOpen, onOpen, onClose } = useDisclosure()
const toast = useToast()
const initialRef = useRef()
const handleFileInput = (e) => {
const file = e.target.files[0]
setPreviewSource(URL.createObjectURL(file))
setImage(file)
setFileInputState(e.target.value)
}
const handleSubmit = (e) => {
e.preventDefault()
if (!image) return
let formData = new FormData()
formData.append('image', image)
formData.append('caption', caption)
addPost(formData)
toast({
title: 'Hurray!!! 🎉',
description: 'Post added ✌',
status: 'success',
duration: 1500,
isClosable: true
})
onClose()
setCaption('')
setFileInputState('')
setPreviewSource('')
}
return (
<>
<Button
fontWeight="medium"
size="md"
colorScheme="yellow"
_active={{
transform: 'scale(0.95)'
}}
onClick={onOpen}
>
{children}
</Button>
<Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader fontWeight="bold">Add Post</ModalHeader>
<ModalCloseButton />
<form onSubmit={handleSubmit}>
<ModalBody pb={6}>
<FormControl>
<FormLabel>Photo</FormLabel>
<input
type="file"
name="image"
ref={initialRef}
onChange={handleFileInput}
value={fileInputState}
required
/>
</FormControl>
{previewSource && (
<img
src={previewSource}
alt="chosen"
height="300px"
width="300px"
style={{ margin: '15px auto' }}
/>
)}
<FormControl mt={4}>
<FormLabel>Caption</FormLabel>
<Input
placeholder="Caption goes here..."
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
/>
</FormControl>
</ModalBody>
<ModalFooter>
<Button mr={4} onClick={onClose}>
Cancel
</Button>
<Button type="submit" colorScheme="yellow" mr={3}>
Create
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
</>
)
}
You'll be able to test the AddPost
component after creating the navbar. Your Navbar will have a title on the left, Add post, login/logout and darkmode toggle buttons on the right. Go ahead and create a new file Navbar.js
in /components
folder.
/src/components/Navbar.js
import Link from 'next/link'
import { Button } from '@chakra-ui/button'
import { Flex, Text } from '@chakra-ui/layout'
import { DarkModeSwitch } from './DarkModeSwitch'
import { AddPost } from './AddPost'
const Navbar = () => {
return (
<Flex
justifyContent="space-between"
w="80%"
flexDirection={['column', 'row']}
m={4}
>
<Text mb={[4, 0]} textAlign="center" fontWeight="bold" fontSize="2xl">
@Scrapbook
</Text>
<Flex justifyContent="space-between">
<AddPost>Add Post</AddPost>
<a href="/api/auth/login">
<Button variant="solid" colorScheme="blue" mx={3} size="md">
Login
</Button>
</a>
<DarkModeSwitch />
</Flex>
</Flex>
)
}
export default Navbar
At this point, you'll be able to add and display your scrapbook posts. Let's add authentication using Auth0.
Setup Authentication with Auth0
If you are already an Auth0 user login to your account or create a free one today. Create a new Regular web application. You'll use the auth0-next.js sdk to connect Auth0 with your application. Select Next.js when it asks what technology you're using. The following secrets are required to configure Auth0 with your Next.js application. Go to the Settings tab and add the new Auth0 secrets to the .env
file.
/.env
...
AUTH0_SECRET=<any secret string of length 32>
AUTH0_BASE_URL='http://localhost:3000'<Your application base URL>
AUTH0_ISSUER_BASE_URL=<URL of your tenant domain>
AUTH0_CLIENT_ID=<Your application's client Id>
AUTH0_CLIENT_SECRET=<Your application's client secret>
If you scroll down a little in the Settings tab, you will find a section Application URIs. Add the following callback URLs. Add the base URL of your application if you're using codesandbox.
- Allowed Callback URLs: http://localhost:3000/api/auth/callback, /api/auth/callback
- Allowed Logout URLs: http://localhost:3000,
Create a new file auth/[...auth0].js
inside the /pages/api
directory. Any route you add inside brackets([]
) will be treated as dynamic route.
/src/pages/api/auth/[...auth0].js
import { handleAuth } from '@auth0/nextjs-auth0'
export default handleAuth()
This generates required routes for authentication(/login
, /logout
and /callback
). Under the hood, Auth0 manages the user's authentication state using React Context.
Wrap the inner components of the /pages/_app.js
file with the UserProvider
to use the useUser
hook provided by Auth0 in the entire application.
/src/pages/_app.js
import { UserProvider } from '@auth0/nextjs-auth0'
function MyApp({ Component, pageProps }) {
return (
<UserProvider>
<PostsProvider> ... </PostsProvider>
</UserProvider>
)
}
export default MyApp
Inside /index.js
, you can now use the useUser
hook to retrieve user information. Pass the user as a prop to the Navbar
component to add login
/logout
functionality. Also, let's display You have to log in
if the user is not logged in.
/src/pages/index.js
...
import { useUser } from '@auth0/nextjs-auth0'
export default function Index({ initialPosts }) {
const { posts, setPosts } = useContext(PostsContext)
const { user, error, isLoading } = useUser()
useEffect(...)
if (isLoading) return <div>Loading...</div>
if (error) return <div>{error.message}</div>
return (
<Flex flexDirection="column" justifyContent="center" alignItems="center">
<Navbar user={user} />
{user ? (
<Flex w="100%" flexDirection="column" my={8}>
{!posts.length ? (
<Flex
h={['30vh', '50vh']}
w="100%"
justifyContent="center"
alignItems="center"
>
<Text fontSize={['2xl', '3xl']} opacity="0.2">
No Posts Added
</Text>
</Flex>
) : (
<Grid
templateColumns={[
'repeat(1, 1fr)',
'repeat(2, 1fr)',
'repeat(3, 1fr)'
]}
gap={6}
m="0 auto"
w={['90%', '85%']}
>
{posts.map((post) => {
console.log(post)
return <Card post={post.fields} key={post.id} />
})}
</Grid>
)}
</Flex>
) : (
<Flex
h={['30vh', '50vh']}
w="100%"
justifyContent="center"
alignItems="center"
>
<Text fontSize={['2xl', '3xl']} opacity="0.2">
You have to login
</Text>
</Flex>
)}
</Flex>
)
}
export async function getServerSideProps(context) { ... }
Update the Navbar.js
for logging in and out a user. Also, you should make sure that only a logged-in user can add scrapbook posts.
/src/components/Navbar.js
import { Button } from '@chakra-ui/button'
import { Flex, Text } from '@chakra-ui/layout'
import { DarkModeSwitch } from './DarkModeSwitch'
import { AddPost } from './AddPost'
const Navbar = ({ user }) => {
return (
<Flex
justifyContent="space-between"
w="80vw"
flexDirection={['column', 'row']}
m={4}
>
<Text mb={[4, 0]} textAlign="center" fontWeight="bold" fontSize="2xl">
@Scrapbook
</Text>
<Flex justifyContent="space-between">
{user && <AddPost>Add Post</AddPost>}
{user ? (
<a href="/api/auth/logout">
<Button variant="solid" colorScheme="blue" mx={4} size="md">
Logout
</Button>
</a>
) : (
<a href="/api/auth/login">
<Button variant="solid" colorScheme="blue" mx={4} size="md">
Login
</Button>
</a>
)}
<DarkModeSwitch />
</Flex>
</Flex>
)
}
export default Navbar
Add Authentication to API Routes
Only authenticated users should be able to access the API. You must also associate each post with a specific user and display just the posts that belong to that person. To obtain user information, Auth0 provides withApiAuthRequired
and getSession
.
Update the API routes as follows:
/pages/api/getPost.js
// For retreving posts from Airtable
import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
import { table, minifyRecords } from '../../utils/Airtable'
export default withApiAuthRequired(async (req, res) => {
const { user } = await getSession(req, res)
try {
const records = await table
.select({ filterByFormula: `userId= '${user.sub}'` })
.firstPage()
const minfiedItems = minifyRecords(records)
res.status(200).json(minfiedItems)
} catch (error) {
console.error(error)
res.status(500).json({ msg: 'Something went wrong! 😕' })
}
})
/pages/api/createPost.js
import nextConnect from 'next-connect'
import multer from 'multer'
import { table, minifyRecords } from '../../utils/Airtable'
import cloudinary from '../../utils/cloudinary'
import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
// multer config
const upload = multer({
storage: multer.diskStorage({}),
fileFilter: (req, file, cb) => {
let ext = path.extname(file.originalname)
if (ext !== '.jpg' && ext !== '.jpeg' && ext !== '.png') {
cb(new Error('File type is not supported'), false)
return
}
cb(null, true)
}
})
const createPost = nextConnect({
onError(error, req, res) {
res.status(501).json({ error: error.message })
}
})
// Adds the middleware to Next-Connect
createPost.use(upload.single('image'))
createPost.post(async (req, res) => {
const { user } = getSession(req, res)
// Uploading file to cloudinary
const result = await cloudinary.uploader.upload(req.file.path)
// Create a new record with required fields
const post = {
image: result.secure_url,
caption: req.body.caption,
cloudinaryId: result.public_id,
userId: user.sub
}
// Create a record with the above fields in Airtable
// the 'create' method accepts and returns an array of records
const newRecords = await table.create([{ fields: post }])
res.status(200).json(minifyRecords(newRecords)[0])
})
export default withApiAuthRequired(createPost)
export const config = {
api: {
bodyParser: false
}
}
To access the protected API, you should include the user's context (such as Cookies
) along with the request. Otherwise, you will receive the error message not_authenticated
. Change the fetch
request within getServerSideProps
to include the user's session token as a cookie.
/src/pages/index.js
...
export async function getServerSideProps(context) {
try {
const res = await fetch('http://localhost:3000/api/posts', {
headers: { Cookie: context.req.headers.cookie }
})
return {
props: {
initialPosts: await res.json()
}
...
}
}
That's all! Now you can see only the posts you have added. Without logging in, you won't be able to access the API routes. Check my app here. If you encounter any problems, try to solve them using this source code. If you prefer downloading, here's the entire codebase on GitHub.
Next Steps
In this tutorial, you have built a full-stack Jamstack app with media management and authentication. You can include some extra features like:
- Updating and deleting the posts.
- At present, you can only upload small images. Find a way to upload large images to your application.
- You can perform media transformations with Cloudinary. Optimize your images before uploading to save space in your cloud.
- Try to display the user profile and include a route to update or delete the user.
- Try thinking of some more crazy ways to add additional functionality and share them with the community.
For media management, I prefer Cloudinary. They allow you to store all of your media on a separate cloud other than your database. Auth0 provides an extra layer of protection to your app. You can personalize the login/signup pages to reflect your brand. Airtable is another powerful tool that I like because of its spreadsheet-like user interface. Based on your requirements, you can use any database with Jamstack apps. As previously said, you can use Jamstack to develop full-stack apps by combining various technologies based on your needs.
Content created for the Hackmamba Jamstack Content Hackathon with Auth0 and Cloudinary.