Blogging as a technical expertise or professional is one way to validate our skills, share knowledge with others, and also grow ourself in any technical field. Most times setting up a blog might be so burdensome, especially when considering the tech stacks to use so as to keep it simple.
What we will be building
In this article, we will be learning how to set up a simple Jamstack blog using Xata, Cloudinary, react-markdown, and Netlify. We will be utilizing the power of Jamstack and some serverless technologies to achieve our goal.
Here is the source code for the working application.
Prerequisites and what to do
- Set up our Xata account.
- Set up our Cloudinary account.
- Set up our application using Next.js and Chakra UI.
- Basic Knowledge of JavaScript, React, or Next.js.
Setting up Xata and creating our first database table
Xata is a Serverless Data Platform. We can think of it as the combination of a serverless relational database, a search engine, and an analytics engine, all behind a single consistent API. It has first-class support for branches, a workflow for zero-downtime schema migrations, as well as support for edge caching.
To set up Xata, we first need an account. We can either use the SDK for our development or the REST API generated for our table to interact with Xata. For the purpose of this article, we will be using the Xata SDK for our development.
- Go to Xata and signup to get access.
- We login to our account and create a workspace. We can create as many workspaces as we want depending on our subscription plan.
- Create a database inside the workspace as shown below:
- Proceed to create a table and add our table items or data. We can specify data types for each item in our table depending on what we will store in them.
Our table items for the blog post are:
title - string
body - string
image - string (we will be storing the image url here from cloudinary)
tags - multiselect (this accepts array of data)
Creating Cloudinary account for our image transformation
Cloudinary is a tool that helps with the transformation of images and videos to load faster with no visual degradation, automatically generates an image and video variants, and delivers high-quality responsive experiences to increase conversions. Whatever media optimizations we need for better performance in our applications can be achieved with this tool.
To set up our Cloudinary, we also have to create an account and get our cloud space on Cloudinary. We can access Cloudinary in many ways too using APIs or SDKs. Cloudinary offers its SDKs in different languages so feel free to explore it outside Jamstack as well. For the purpose of this article, we will be using the API from our Cloudinary cloud to generate social cards for our blog posts. No need to open Figma or other design tools every time, Cloudinary got you!
- Go to Cloudinary and sign up to get access to a cloud.
- We will login and confirm we can see our dashboard with information like cloudname, API Keys etc. We will be needing them later.
Create Next JS app and connect Xata SDK
To set up a Next.js App, we run either of the following commands depending on our preference
npx create-next-app@latest
or
yarn create next-app
or
pnpm create next-app
After the setup, we will run this command to start the application.
npm run dev
We should get a screen like this:
Now we have our application up and running, let's get to the real deal. We will be initializing the Xata SDK and writing a function that uses our cloudname and image public id on Cloudinary to generate a social card for our blog posts.
Setting up APIs to interact with our Xata DB
- Firstly we open our terminal and run these commands in our root directory and project directory respectively
- Install the Xata CLI
npm install @xata.io/cli -g
or
npm install @xata.io/client@latest
- Initialize your project locally with the Xata CLI
xata init --db https://<your workspace name>-cktogf.us-east-1.xata.sh/db/<database name>
These commands can easily be copied from our database UI in Xata. See an example below:
The last command will ask for some configurations according to our preference before it goes ahead to install the SDK. Also, it will trigger a window so we can connect our API key automatically to our project. See the example here:
Now we are all set to start using Xata in our project!
Social Image Generation for our Blog with Cloudinary
Now, let's set up our serverless function for image transformation using Cloudinary.
- First, we need our cloudname, a card design that contains either our brand or whatever we want to use and identify with as our social card, and a space where we will overlay text automatically using Cloudinary function. In other words, once anybody sees this social card, they know the owner of the articles.
- We can get design inspiration from this article or this Figma design. Below is my default social card and the upload to Cloudinary. The image can be our logo, brand image, or anything we want to be our brand.
- Go to our project directory, create a directory component and create a file
GenerateImage.js
- Paste the following code inside the file
function cleanText(text) {
return encodeURIComponent(text).replace(/%(23|2C|2F|3F|5C)/g, '%25$1');
}
/**
* Generates a social sharing image with custom text using Cloudinary’s APIs.
*
* @see https://cloudinary.com/documentation/image_transformations#adding_text_captions
*
*/
export default function generateSocialImage({
title,
tagline,
cloudName,
imagePublicID,
cloudinaryUrlBase = 'https://res.cloudinary.com',
titleFont = 'righteous',
titleExtraConfig = '',
taglineExtraConfig = '',
taglineFont = 'caveat',
imageWidth = 1280,
imageHeight = 669,
textAreaWidth = 760,
textLeftOffset = 480,
titleGravity = 'south_west',
taglineGravity = 'north_west',
titleLeftOffset = null,
taglineLeftOffset = null,
titleBottomOffset = 254,
taglineTopOffset = 445,
textColor = 'FFFFFF',
titleColor,
taglineColor,
titleFontSize = 64,
taglineFontSize = 48,
version = null,
}) {
// configure social media image dimensions, quality, and format
const imageConfig = [
`w_${imageWidth}`,
`h_${imageHeight}`,
'c_fill',
'q_auto',
'f_auto',
].join(',');
// configure the title text
const titleConfig = [
`w_${textAreaWidth}`,
'c_fit',
`co_rgb:${titleColor || textColor}`,
`g_${titleGravity}`,
`x_${titleLeftOffset || textLeftOffset}`,
`y_${titleBottomOffset}`,
`l_text:${titleFont}_${titleFontSize}${titleExtraConfig}:${cleanText(
title,
)}`,
].join(',');
// configure the tagline text
const taglineConfig = tagline
? [
`w_${textAreaWidth}`,
'c_fit',
`co_rgb:${taglineColor || textColor}`,
`g_${taglineGravity}`,
`x_${taglineLeftOffset || textLeftOffset}`,
`y_${taglineTopOffset}`,
`l_text:${taglineFont}_${taglineFontSize}${taglineExtraConfig}:${cleanText(
tagline,
)}`,
].join(',')
: undefined;
// combine all the pieces required to generate a Cloudinary URL
const urlParts = [
cloudinaryUrlBase,
cloudName,
'image',
'upload',
imageConfig,
titleConfig,
taglineConfig,
version,
imagePublicID,
];
// remove any falsy sections of the URL (e.g. an undefined version)
const validParts = urlParts.filter(Boolean);
// join all the parts into a valid URL to the generated image
return validParts.join('/');
}
So from this code sample, we will be needing our cloudname and our custom image public id (we will upload it from our account on Cloudinary. It's quite straightforward to generate our social cover image. Other optional config items are fonts for our blog title and taglines. There are defaults for them in the function but we can override them wherever we call the function by passing it as a parameter. We will see this in our CreateModal
component later, how we will automatically overlay text on our design image and transform it each time we create a blog post.
We are all set for our Cloudinary image transformation set-up!
Building our CRUD APIs and Interfaces using Next.js and Chakra UI
We are getting closer to our final product. Next, we will be writing CRUD (Create, Read, Update, Delete) functions we will use to interact with our Xata DB and then present the data on the user interface.
For our user interface, we will be using Next.js and Chakra UI, our APIs will be stored in the Next.js api
directory, and our dynamic pages in the pages
directory.
Our APIs for Interacting with Xata are as follows:
import { getXataClient } from '../../src/xata.js'
const xata = getXataClient()
// allpost.js. API to get/read all our blog posts
export default async function getAllXata(req, res) {
const records = await xata.db.posts.getAll();
return res.json({
ok: true,
posts: records
})
}
// create.js. API to get create our blog posts
export default async function createToXata(req, res) {
let posts = req.body
await xata.db.posts.create(posts)
res.json({
ok: true,
})
}
// update.js. API to update a blog post
export default async function updateToXata(req, res) {
let id = req.body.id
let post = {
title: req.body.post.title,
body: req.body.post.body,
image: req.body.post.image,
tags: req.body.post.tags
}
console.log(id, post)
await xata.db.posts.update(id, post);
res.json({
ok: true,
})
}
//post.js API to read one post
export default async function getOneXata(req, res) {
let id = req.query
console.log(id)
const record = await xata.db.posts.read(id);
return res.json({
ok: true,
post: record
})
}
//delete.js API to delete a blog post
const deleteItem = async (id) => {
return await xata.db.posts.delete(id)
}
export default async function deleteFromXata(req,res) {
const { id } = req.body
await deleteItem(id)
res.json({
ok: true,
})
}
We can also get these codes from our Xata workspace according to the image below:
To start designing our user interface, we will install chakra-ui, chakra-ui/icons, react-icons, react-markdown and react-tostify. We will use Chakra UI and React Icons libraries to build the user interfaces of our application, React Markdown to allow markdown in our blog textarea
field and React Toastify to show updates to user when they make any API call.
- Using NPM
npm i @chakra-ui/react @chakra-ui/icons @emotion/react @emotion/styled framer-motion react-icons react-markdown react-toastify
- Using Yarn
yarn add @chakra-ui/react @chakra-ui/icons @emotion/react @emotion/styled framer-motion react-icons react-markdown react-toastify
Then we will wrap our App
with providers from chakra-ui and also pass react-toastify to our App
body as shown below:
import { ChakraProvider } from '@chakra-ui/react'
import {ToastContainer} from 'react-toastify'
import "react-toastify/dist/ReactToastify.min.css";
function MyApp({ Component, pageProps }) {
return (
<ChakraProvider>
<Component {...pageProps} />
<ToastContainer />
</ChakraProvider>
)
}
export default MyApp
We will proceed to create our pages inside the pages
directory. We will be updating the index.js
page and creating two dynamic routed pages: one in the pages/[id].js
directory and another in the page/update/[id].js
directory which is a sub directory to pages
as shown in the previous application tree.
So we can access https://appurl/id
and https://appurl/update/id
when we want to view a single blog post and update a blog post respectively. Read more about dynamic routing in Next.js here
For our pages/index.js
file, we will use this code below:
import { useState, useEffect } from 'react'
import CreatePost from '../components/CreateModal'
import { FaGithub } from 'react-icons/fa'
import { Icon, ButtonGroup, Text, Spacer, Link } from '@chakra-ui/react'
import AllPosts from '../components/AllPost'
export default function Home() {
const [posts, setPosts] = useState([])
useEffect(() => {
const getData = async () => {
await fetch('/api/allposts', {
method: 'GET',
}).then((response) => response.json())
.then((data) => setPosts(data.posts));
}
getData();
}, [])
return (
<>
<main className='main'>
<div className='grid'>
<Text as='b' fontSize='20px' color='black' >
Blog with Xata and Cloudinary
</Text>
<Spacer />
<ButtonGroup gap={3} ml={5}>
<CreatePost />
<Link href='https://github.com/DesmondSanctity/xata-cloudinary-blog' isExternal><Icon as={FaGithub} w={10} h={10} /></Link>
</ButtonGroup>
</div>
<AllPosts posts={posts} />
</main>
<footer className='footer'>
<a href="https://dexcode.xyz" target="_blank" rel="noopener noreferrer">
Created by <b>Anon</b> ⚡️
</a>
</footer>
</>
)
}
For our pages/[id].js
file, we will use this code below:
import { useRouter } from 'next/router'
import { Box, Image, Text, Icon, Link, Container, Spinner, Alert, AlertDescription, AlertIcon, AlertTitle } from '@chakra-ui/react'
import { FaGithub } from 'react-icons/fa'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import useSWR from 'swr'
const fetcher = (...args) => fetch(...args).then((res) => res.json())
const PostDetails = () => {
const router = useRouter();
const { id } = router.query;
console.log({ router });
//Getting data from xata using swr
const { data, error } = useSWR(`/api/post?id=${id}`, fetcher)
if (error) return (
<div><Alert status='error'>
<AlertIcon />
<AlertTitle>Error!</AlertTitle>
<AlertDescription>Failed to Load.</AlertDescription>
</Alert></div>
)
if (!data) return <div><Spinner color='red.500' /></div>
const post = data.post;
return (
<main className='main'>
<div className='grid'>
<Text as='b' fontSize='20px' color='black' mt={5}>
Blog with Xata and Cloudinary
</Text>
<Link href='https://github.com/DesmondSanctity/xata-cloudinary-blog' isExternal><Icon as={FaGithub} w={10} h={10} mt={7} ml={5} /></Link>
</div>
<Container maxW='container.sm'>
<Image src={post.image} alt='blog-image' />
<Box p='6'>
<Box
mt='1'
fontWeight='light'
lineHeight='tight'
ml={5}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight, rehypeRaw, rehypeSanitize]}>{post.body}</ReactMarkdown>
</Box>
</Box>
</Container>
</main>
)
}
export default PostDetails
We used SWR (stale-while-revalidate) to fetch our data because it is an easier way to handle fetching data at the request time in Next.js. The team behind Next.js has created a React hook for data fetching called SWR. It is highly recommended if you’re fetching data on the client side. It handles caching, revalidation, focus tracking, refetching at intervals, and more. Read more about it here.
For our pages/update/[id].js
file, we will use this code below:
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import {
Button,
Textarea,
Input,
FormControl,
FormLabel,
Container,
Text,
Spacer,
Icon,
Link,
Spinner,
Alert,
AlertDescription,
AlertIcon,
AlertTitle
} from '@chakra-ui/react'
import { FaGithub } from 'react-icons/fa'
import { toast } from 'react-toastify'
import generateSocialImage from '../../components/GenerateImg'
import useSWR from 'swr'
const fetcher = (...args) => fetch(...args).then((res) => res.json())
const UpdatePost = () => {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [tags, setTags] = useState('');
const router = useRouter();
const { id } = router.query;
//Get data from xata db
const { data, error } = useSWR(`/api/post?id=${id}`, fetcher)
if (error) return (
<div><Alert status='error'>
<AlertIcon />
<AlertTitle>Error!</AlertTitle>
<AlertDescription>Failed to Load.</AlertDescription>
</Alert></div>
)
if (!data) return <div><Spinner color='red.500' /></div>
// store data in state
const res = data.post;
// handle form submit
const handleSubmit = async () => {
//Convert string tags to array
const newTags = tags || res.tags.toString();
console.log(newTags)
// Reducing number of accepted tags to 4 if user inputs more
const tagArr = newTags.split(/[, ]+/);
let tags_new;
if (tagArr.length >= 4) {
tags_new = tagArr.slice(0, 4)
} else tags_new = tagArr;
console.log(tags_new);
//Generate social card with cloudinary
const socialImage = generateSocialImage({
title: title || res.title,
tagline: tags_new.map(tag => `#${tag}`).join(' '),
cloudName: 'dqwrnan7f',
imagePublicID: 'dex/example-black_iifqhm',
});
console.log(socialImage);
//Make add create request
let post = {
title: title || res.title,
body: body || res.body,
image: socialImage,
tags: tags_new,
}
const response = await fetch('/api/update', {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ post, id })
})
if (response.ok) {
toast.success("post updated successfully", {
theme: "dark",
autoClose: 8000
})
window?.location.replace('/');
}
}
return (
<main className='main'>
<div className='grid'>
<Text as='b' fontSize='20px' color='black' mt={5} >
Blog with Xata and Cloudinary
</Text>
<Spacer />
<Link href='https://github.com/DesmondSanctity/xata-cloudinary-blog' isExternal><Icon as={FaGithub} w={10} h={10} mt={7} ml={5} /></Link>
</div>
<Container maxW='4xl' centerContent>
<FormControl >
<FormLabel>Post Title</FormLabel>
<Input placeholder='Title' defaultValue={res.title} onChange={e => { setTitle(e.target.value) }} />
</FormControl>
<FormControl mt={4}>
<FormLabel>Post Tags</FormLabel>
<Input placeholder='add tags separated by commas' defaultValue={res.tags} onChange={e => { setTags(e.target.value) }} />
</FormControl>
<FormControl mt={4}>
<FormLabel>Post Body</FormLabel>
<Textarea placeholder='you can use markdown here' size='sm' defaultValue={res.body} onChange={e => { setBody(e.target.value) }} />
</FormControl>
<Button colorScheme='black' variant='outline' type='submit' mt={5} onClick={() => handleSubmit()}>Submit</Button>
</Container>
</main>
)
}
export default UpdatePost
For our other reusable components AllPosts.js
and CreateModal.js
, we have:
AllPost.js
import NextLink from 'next/link'
import { Box, Image, Badge, Flex, Spacer, ButtonGroup, Link } from '@chakra-ui/react';
import { DeleteIcon, EditIcon, ExternalLinkIcon } from '@chakra-ui/icons'
import { toast } from 'react-toastify'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const AllPosts = ({ posts }) => {
const deleteData = async (id) => {
const { status } = await fetch('/api/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id }),
})
if (status === 200) {
toast.success("post deleted successfully", {
theme: "dark",
autoClose: 5000
})
}
window?.location.reload()
}
return (
<div className='grid'>
{
posts && posts.map((post, index) => {
return (
<div className='card' key={index}>
<Box maxW='sm' borderWidth='1px' borderRadius='lg' overflow='hidden' mt={5} mb={2}>
<Image src={post.image} alt='blog-image' />
<Box p='6'>
<Flex>
<Badge borderRadius='full' px='2' colorScheme='teal'>
Tags:
</Badge>
{post.tags.length > 0 && post.tags.map((tag, index) => {
return (
<Box key={index}
color='gray.500'
fontWeight='semibold'
letterSpacing='wide'
fontSize='xs'
textTransform='lowercase'
ml='2'
>
{(index ? ',' : '') + ' ' + tag}
</Box>
)
})}
<Spacer />
<ButtonGroup gap={2}>
<NextLink href={`/update/${post.id}`} legacyBehavior passHref>
<Link><EditIcon /></Link>
</NextLink>
<DeleteIcon onClick={() => deleteData(post.id)} mt={1} />
</ButtonGroup>
</Flex>
<Box
mt='1'
fontWeight='semibold'
lineHeight='tight'
noOfLines={3}
>
<NextLink href={`/${post.id}`} legacyBehavior passHref>
<Link>{post.title}<ExternalLinkIcon mx='2px' /></Link>
</NextLink>
</Box>
<Box
mt='1'
fontWeight='light'
lineHeight='tight'
noOfLines={5}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body}</ReactMarkdown>
</Box>
</Box>
</Box>
</div>
)
})
}
</div>
)
}
export default AllPosts
CreateModal.js
import { useState } from 'react'
import {
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Button,
Textarea,
Input,
FormControl,
FormLabel,
} from '@chakra-ui/react'
import { toast } from 'react-toastify'
import generateSocialImage from './GenerateImg'
const PostForm = () => {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [tags, setTags] = useState("");
//Convert string tags to array
const newTags = tags;
const handleSubmit = async () => {
if (title == '' || body == '' || tags == '') {
toast.warn("post cannot be empty", {
theme: "dark",
autoClose: 8000
})
} else {
const tagArr = newTags.split(/[, ]+/);
let tags_new;
if (tagArr.length >= 4) {
tags_new = tagArr.slice(0, 4)
} else tags_new = tagArr;
console.log(tags_new);
//Generate social card
const socialImage = generateSocialImage({
title: title,
tagline: tags_new.map(tag => `#${tag}`).join(' '),
cloudName: 'dqwrnan7f',
imagePublicID: 'dex/example-black_iifqhm',
});
console.log(socialImage);
//Make add create request
let posts = {
title: title,
body: body,
image: socialImage,
tags: tags_new,
}
const response = await fetch('/api/create', {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(posts)
})
if (response.ok) {
toast.success("post created successfully", {
theme: "dark",
autoClose: 8000
})
window?.location.reload()
}
}
}
return (
<>
<FormControl >
<FormLabel>Post Title</FormLabel>
<Input placeholder='Title' onChange={e => { setTitle(e.target.value) }} required />
</FormControl>
<FormControl mt={4}>
<FormLabel>Post Tags</FormLabel>
<Input placeholder='add tags separated by commas' onChange={e => { setTags(e.target.value) }} required />
</FormControl>
<FormControl mt={4}>
<FormLabel>Post Body</FormLabel>
<Textarea placeholder='you can use markdown here' size={'lg'} onChange={e => { setBody(e.target.value) }} required />
</FormControl>
<Button colorScheme='black' variant='outline' mt={5} onClick={() => handleSubmit()}>Submit</Button>
</>
)
}
const CreatePost = () => {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<>
<Button colorScheme='black' variant='outline' onClick={onOpen} mt={2} size={'sm'}>Create Post</Button>
<Modal isOpen={isOpen} onClose={onClose} size={'5xl'}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Modal Title</ModalHeader>
<ModalCloseButton />
<ModalBody>
<PostForm />
</ModalBody>
</ModalContent>
</Modal>
</>
)
}
export default CreatePost
We used our generateSocialImage
Cloudinary function inside the createModal
component and update
page to generate a social card. Here is an isolated version:
//Generate social card
const socialImage = generateSocialImage({
title: title,
tagline: tags_new.map(tag => `#${tag}`).join(' '),
cloudName: 'dqwrnan7f',
imagePublicID: 'dex/example-black_iifqhm',
});
console.log(socialImage);
We can see how we passed dynamic data to the function, our public image id, cloudname, title from our blog, and taglines from our blogpost too. Our social card will look like this when the function is executed:
If you followed up till this point, our application is almost ready! Finally, we will go ahead and add some CSS styles to the default stylesheet file in our project globals.css
:
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
body {
color: white;
background: black;
}
}
.main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}
.footer img {
margin-left: 0.5rem;
}
.footer a {
display: flex;
justify-content: center;
align-items: center;
}
.grid {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 80%;
margin-top: 3rem;
margin: auto;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
@media (max-width: 600px) {
.grid {
max-width: 100%;
flex-direction: column;
}
}
We will then run our application using any of these commands to see the finished product.
# NPM
npm run dev
# Yarn
yarn run dev
Now we have our blog running on Xata and Cloudinary serverless provisions. We can go ahead and improve the user interface, make it more responsive and even add some animations too. We can also host it on services like Netlify, Vercel, and any other client-side hosting platforms we can think of. For this article, we will be deploying to Netlify.
One easy way to deploy to Netlify is to push our code to Github, connect our Github to Netlify and select the repository that contains our project. We will select the Next.js preset build command and everything will run and deploy automatically with fewer or no configurations. Check this article for more insight on deploying to Netlify.
Our live link on Netlify is ready.
Conclusion
So we were able to learn from this article how we can use Jamstack through Next.js, Cloudinary, Xata, and Chakra UI to build a blog application with CRUD functions without setting up any server. Feel free to comment on what you learned, what we did not cover, possible improvements, and also any questions you might have. I will be glad to take your feedback and answer your questions.
Resources
Here are some resources that might be helpful: