🌟 Help others discover us, star our GitHub repo! 🌟

Mobile Menu

Building a CSS Tricks Website Clone with Webiny and NextJS

Emmanuel AiyenigbaTwitter
August 10, 2022

There are many benefits of using a Headless Content Management Systems (CMS). They are often a tool of choice for building modern applications. There are a lot of different CMS that solve problems associated with managing content across platforms, but only a few come with important features such as scalability, data privacy, efficient cost of running & maintenance, and ease of adaption. Webiny implements these much-needed features.

Aim

This article aims at introducing you to headless CMS as well as teaching you how to build a serverless blog, in this case, CSS Tricks clone using Webiny and NextJS. You will also get exposed to the importance of a serverless approach in building modern applications.

Introduction to Headless CMS

A Headless Content Management System, or headless CMS, is a backend-only system that acts primarily as a content repository. A headless CMS makes content accessible via a content delivery API (this could be a GraphQL or REST API) for display on any device without a built-in, frontend, or presentation layer. A headless CMS lets you connect more than one presentation layer to a content repository; this allows you to avoid going through the hurdles of building and maintaining a server.

Why Webiny

Webiny allows you to self-host your application on your own AWS cloud, bringing your data under your own rules, and providing you with that much-needed data privacy. This also reduces infrastructure costs. You have the liberty to scale your application when you need it. Webiny also offers advanced features such as a plugin ecosystem, security, and infrastructure as code.

Requirements

To follow this project you need:

  • Node.js: ensure to have Node.js version 14 or higher installed on your machine. To check the version of Node.js on your machine, run node --version
  • yarn ^1.22.0 || >=2: Webiny works on both the classic and berry versions of yarn
  • AWS account: Webiny allows you to self-host your application on the cloud. You must have a valid AWS account and user credentials set up on your machine.

Setting-Up and Configuring Our Webiny Project

Let's set up and configure a new Webiny project to begin building. Run this command inside your terminal

npx create-webiny-project css-tricks-clone

Following the instructions in the terminal will help you:

  • Initialize project folder
  • setup yarn
  • install template package
  • Initialize git

Then you will:

  • Be provided with some options to choose the AWS region to which your new project will deploy to.
  • Select your database of choice. Webiny supports two types as at the time of writing this article: DynamoDB and DynamoDB + Elasticsearch. You are to pick the one that suits your project needs. If you intend to have a small to medium size project, DynamoDB should be your preferred database. We will use DynamoDB for this tutorial.

After the above steps, Webiny will automatically install the necessary dependencies for our project.

Once the project has been created, it's time to deploy it into your AWS account. To do this, run the following command in your terminal:

yarn webiny deploy

This command will first build the project along with needed cloud infrastructure resources. The first deployment can take up to 20 minutes, you need to be patient and let the process finish.

When this in done, you will be presented with the following URLs:

  • Main GraphQL API: this is the URL of your project's GraphQL API
  • Admin app: this is an administrative area where you can control everything in your content repository.
  • Public website:
  • Website URL
  • Website preview URL

Incase you misplace any of these URLs, you can always run yarn webiny info command in your Webiny project folder to get them.

Let's head over to our admin app URL and set-up our Headless CMS so that you we can start building the frontend.

  • Once you have logged in, click New Content Model

Webiny welcome screen

Let's create our content model

The content groups screen in Webiny

Provide the content model with the necessary entries - name, content model group and description.

New content model screen

For the purpose of this tutorial, we set the model name to "Blog" (Model name should be in singular form); the content model group to "Ungrouped" and the description to "CSS Tricks clone".

  • Let's insert fields into our Blog model. The following are the fields we shall be using for the project:
    • Title: this is your post title and field type will be text
    • Post id: a number.
    • Body: this is a rich text with formatting and reference media.
    • Author's photo: field type is file and it will allow image only.
    • Content photo: field type is file and it will allow image only
    • Date: this is the date field for when a blog post was made.
    • Author: field type is text.
    • tag: field type is text. Enable use as a list of texts for this field, this is because tag is expected to be an array.

Creating the blog content model

Open the menu again and click on Headless CMS > Ungrouped > Blog. Make new entries inside the model - as many as you want.

Creating the blog article content

Now that we are done with creating and editing our models, let's go over to Settings to create our API key and also retrieve the token. We will need this token to make a request to the headless CMS via the GraphQL endpoint.

Click on Settings > API Keys and create a new key that allows read access to the Headless CMS and File Manager. Once you click “Save” you will be given a new API key. Copy that and keep it somewhere safe. We will use it to connect our frontend.

Webiny menu settings panel

Creating the Webiny API token

Configuring the GraphQL API

Go to the API playground to test out your API and see if things are working fine. To navigate to the API playground, go to your admin area and click “API playground” on the menu tab.

There are 3 tabs in the GraphQL Playground, what you will need is the Headless CMS - Read API

The API playground

Let's test our API by writing a query to fetch the contents inside our headless CMS:

{ listBlogs{ data{ postId title body authorsPhoto contentPhoto date author tag } } }

This should give us all the content in our Headless CMS.

Data JSON response in the API playground

Did I hear you say we are done with setting-up our project admin area? Oh yes! You guessed right.

Now, let's begin building the frontend.

Building the Application With NextJS

To create a NextJS application, run. Note that, your NextJS application should be on the same level as your Webiny project and not inside it.

npx create-next-app@latest # or yarn create next-app # or pnpm create next-app

The name of our application is css-tricks-frontend.

We shall be using graphql-request to make API request to our headless CMS. Let's install that:

yarn add graphql-request #or npm install graphql-request

Alternatively, you can use Apollo GraphQL or any dependency of your choice to make API request.

After installation is complete, we can begin writing our code.

Folder Structure

It is important to keep your code organized for easy readability and maintainability. Here is what our NextJS project folder looks like.

  • .env.local: this will store the GraphQL endpoint and CMS token.
  • /lib/context.js: this file will contain our application logic.
  • /pages/header.js : application header.
  • /pages/footer.js: application footer
  • /pages/components/home.js: home page.
  • /pages/components/[post].js: this route will be our post details.
  • /styles/App.css: CSS file.

Let's begin writing some code

.env.local

This file is used for controlling our app’s environment constants. We would store our application secrets here and then ask git to ignore this file, this is so that we can keep our app secured by not pushing token secrets to GitHub. Please add the access token and API URL you copied in the previous steps.

NEXT_PUBLIC_TOKEN_SECRET= your_token_here NEXT_PUBLIC_CMS_ENPOINT= your_CMS_enpoint_here

The code below is our Context API logic. Context API enables you to exchange unique details and assists in solving prop-drilling from all levels of your application. We can access the data from Context anywhere within our application.

We will need to access cmsData from other components within our application.

/lib/context.js

import React, {createContext, useEffect, useState} from 'react'; import {GraphQLClient, gql} from 'graphql-request' export const ProductContext = createContext(); const ProductProvider = ({children}) => { //state to store information from the headless cms const [cmsData, setCmsData] = useState({ post: [] }) //useEffect to call graphql endpoint useEffect(() =>{ async function callApi(){ const endpoint = process.env.NEXT_PUBLIC_CMS_ENPOINT const graphQLClient = new GraphQLClient(endpoint, { headers: { authorization: process.env.NEXT_PUBLIC_TOKEN_SECRET } }) //query cms data const queryRequest = gql` { listBlogs{ data{ postId title body authorsPhoto contentPhoto date author tag } } } ` const data = await graphQLClient.request(queryRequest) setCmsData({post: data.listBlogs.data}) } callApi() }, []) return( <ProductContext.Provider value={{ ...cmsData }} > {children} </ProductContext.Provider> ); } export default ProductProvider;

Inside context.js, createContext() is used for proper state management and for reusing stateful logic inside other components of our application.

callApi() calls our project's GraphQL endpoint through the graphql-request package. The result (or response) is stored inside the cmsData state object.

Let's wrap ProductProvider around our entire application and also import styles globally.

In /pages/_app.js add the following:

import '../styles/App.css'; import {ProductProvider} from './context' function MyApp({ Component, pageProps }) { return( <ProductProvider> <Component {...pageProps} /> </ProductProvider> ) } export default MyApp

In /pages/header.js add the following:

import React from 'react' import Link from 'next/link' function Header() { return ( <div className='container-header'> <Link href="/"> <div className='header'> <p>*</p> <h1>CSS-TRICKS |</h1> <p className='digitalocean'>DigitalOcean</p> </div> </Link> <div className='nav-bar'> <p>Articles</p> <p>Videos</p> <p>Almanac</p> <p>Newsletter</p> <p>Guides</p> <p>DigitalOcean</p> <p>Docommunity</p> <p></p> </div> </div> ) } export default Header

Set the background property of the application to look exactly like css-tricks.com

In /style/App.css add the following:

:root{ --featured-img: linear-gradient(180deg,#fff,#262626); } body{ background-image: radial-gradient(50% 50% at top center,rgba(0,0,0,.66),#262626),var(--featured-img); background-size: 120% 2000px,100% 2000px; }

Navigate inside /pages/home.js let's set our homepage to display blog posts

import React, {useContext} from 'react'; import {RichTextRenderer} from '@webiny/react-rich-text-renderer'; import {ProductContext} from '../../lib/context'; import Link from 'next/link'; import Image from 'next/image'; const Home = () => { const getPost = useContext(ProductContext) const getFirstFivePosts = getPost?.post.slice(0,5); const getOtherPosts = getPost?.post.slice(4); const getParagraph = getPost?.post[0]?.body.filter(post => post.type === "paragraph")?.slice(0,2); return ( <div> <div> <div> {getPost && getParagraph && <div className='firstPostContainer'> <div className='image-div'> <Image src={getPost?.post[0]?.contentPhoto} alt="avatar" className='firstPostImage' width={500} height={300} layout="fill" /> </div> <div className='first-post'> <p className='tag'>{getPost?.post[0]?.tag[0]}</p> <Link href={'/components/' + getPost?.post[0]?.postId}> <h2>{getPost?.post[0]?.title}</h2> </Link> <div className="post-intro firstPostIntro"> <RichTextRenderer data={getParagraph} /> </div> <div className='firstPostAuthorInfo'> {getPost?.post[0]?.authorsPhoto.length > 0 && ( <Image src={getPost?.post[0]?.authorsPhoto} alt="avatar" className='avatar' width={40} height={40} layout="fixed" /> )} <p className="author">{getPost?.post[0]?.author}</p> <p className="date">{getPost?.post[0]?.date}</p> </div> </div> </div> } </div> </div> <div className='aside-post-container'> {getFirstFivePosts && getFirstFivePosts.map(res => { return ( <div key={res.postId} className="mini-card module"> <p className='article-date'>Article on {res.date}</p> <Link href={'/components/' + res.postId}> <h4> {res.title} </h4> </Link> <p className='aside-tag'>{res.tag[0]}</p> <div className='author-info firstFourAvatar'> <Image src={res.authorsPhoto} className="avatar" alt="avatar" width={40} height={40} layout="fixed" /> <p>{res.author}</p> </div> </div> ) })} </div> <div className='card-container'> {getOtherPosts && getOtherPosts.map(res => { const paragraph = res.body.filter(post => post.type === "paragraph")?.slice(0,2) return ( <div className='card' key={res.postId}> <p className='tag'>{res.tag[0]}</p> <Link href={'/components/' + res.postId}> <h3>{res.title}</h3> </Link> <div className="post-intro"> <RichTextRenderer data={paragraph}/> </div> <div className='author-info'> <Image src={res.authorsPhoto} alt="avatar" className='avatar' width={40} height={40} layout="fixed" /> <p className="author">{res.author}</p> <p className="date">{res.date}</p> </div> </div> ) }) } </div> <div className="archive"> <button className='button'>KEEP BROWSING IN THE ARCHIVES</button> </div> <div className='aside-post-container'> {getFirstFivePosts && getFirstFivePosts.map(res =>{ return( <div key={res.postId} className="mini-card module"> <p className='article-date'>Article on {res.date}</p> <Link href={'/components/' + res.postId}> <h4> {res.title} </h4> </Link> <p className='aside-tag'>{res.tag[0]}</p> <div className='author-info firstFourAvatar'> <Image src={res.authorsPhoto} className="avatar" alt="avatar" width={40} height={40} layout="fixed" /> <p>{res.author}</p> </div> </div> ) })} </div> </div> ) } export default Home

getParagraph gives the a paragraph of the first post inside the content repository. We need this so we can make the first post bigger than the others.

getFirstFivePosts is the first five posts in our content repository which we shall be displaying as an aside content.

getOtherPosts is every other posts in our content repository.

Each of these was mapped to display the title, tag, author's avatar, author's name and date of post on our home page. It also shows a little of the post content.

Let’s use dynamic route to get and display selected blog post.

[post].js

import React, {useContext, useEffect, useState} from 'react' import Image from 'next/image'; import {useRouter} from 'next/router'; import {GraphQLClient, gql} from 'graphql-request' import Header from './header'; import { RichTextRenderer } from '@webiny/react-rich-text-renderer'; function Post() { const [getPost, setGetPost] = useState() const router = useRouter() const {post} = router.query useEffect(() =>{ async function callApi(){ const endpoint = process.env.NEXT_PUBLIC_CMS_ENPOINT const graphQLClient = new GraphQLClient(endpoint, { headers: { authorization: process.env.NEXT_PUBLIC_TOKEN_SECRET } }) //query cms data const queryRequest = gql` query getBlog($post: String) { listBlogs(where: { postId: $post }){ data{ postId title body authorsPhoto contentPhoto date author tag } } } ` const variables = { post: post } const data = await graphQLClient.request(queryRequest, variables) setGetPost(data.listBlogs.data) data.listBlogs.data.map(res => setGetPost(res)) } callApi() }, [post]) return ( <div className="container" > <Header /> {getPost && ( <div> <p className='tag'>{getPost?.tag[0]}</p> <h1 className='title'>{getPost?.title}</h1> <div className="author-bio author-info"> <Image src={getPost.authorsPhoto} alt="avatar" className='avatar' width={40} height={40} layout="fixed" /> <p className='author'>{getPost.author}</p> <p className='date'>{getPost.date}</p> </div> <div className="article-sponsor"> <p>DigitalOcean joining forces with CSS-Tricks! Special welcome offer: get $100 of free credit.</p> </div> <div className="post-content"> <RichTextRenderer data={getPost?.body} /> </div> </div> )} </div> ) } export default Post

RichTextRenderer is a component we used to render rich text inside our application. Webiny provides this npm package for rendering rich texts. To use this dependency, you need to install it using the following command:

npm install --save @webiny/react-rich-text-renderer

Or if you prefer yarn:

yarn add @webiny/react-rich-text-renderer

The RichTextRenderer component is passed the rich text data to render and then mounted inside the post-content div.

Go ahead to localhost:3000 to run the application. Yay!!! We have our application up and running.

Here is the complete CSS code for our application

/styles/App.css

@import url('https://fonts.googleapis.com/css2?family=Cantarell&family=Lato&family=Open+Sans:wght@300&family=Oxygen:wght@300&family=Roboto:wght@100&family=Rubik:wght@500&family=Ubuntu:wght@300&display=swap'); :root{ --featured-img: linear-gradient(180deg,#fff,#262626); } body{ background-image: radial-gradient(50% 50% at top center,rgba(0,0,0,.66),#262626),var(--featured-img); background-size: 120% 2000px,100% 2000px; } .header{ color: white; cursor: pointer; display: flex; margin-bottom: -5rem; } .header p{ font-size: 4rem; margin-top: -.01rem; } .header .digitalocean{ font-size: 1.1rem; margin-top: 1.05rem; padding-left: .2rem; font-family: 'Rubik', sans-serif; } .header h1{ margin-top: .8rem; font-size: 1.6rem; font-family: Montserrat; font-weight: 300; } .nav-bar{ display: flex; color: #fff; overflow-x: scroll; scrollbar-color: #5e5e5e; } .nav-bar p{ text-transform: uppercase; padding: 0 .7rem; font-family: 'MD Primer Bold', Rubik, Lato, 'Lucida Grande', 'Lucida Sans Unicode',Tahoma,Sans-Serif; font-size: .6rem; font-weight: 600; } .container-header{ background: #111111; margin: -.5rem -.5rem 3rem -.5rem; } @media(min-width: 1240px){ .container-header{ display: flex; margin-bottom: 4rem; } .nav-bar p{ font-size: .8rem; margin-top: 1.28rem; padding-left: 2rem; } .nav-bar{ overflow-x: hidden; } } .first-post{ border: 1px solid black; margin-bottom: 1.4rem; box-sizing: border-box; width: 97%; padding: 0 .3rem; word-wrap: break-word; border-radius: 15px; margin: auto; margin-top: -5rem; background: #fff; position: relative; } .image-div{ box-sizing: border-box; width: 97%; margin: auto; margin-top: 5rem; height: 16rem; padding: 0 .2rem; position: relative; opacity: 0.8; z-index: -1; cursor: pointer; } .firstPostImage{ z-index: -1; border-radius: 15px; } @media(min-width: 800px){ .image-div{ margin-top: 2rem; width: 97%; height: 30rem; border-radius: 8px; opacity: 0.7; margin-right: -3rem; z-index: -1; } .first-post{ /* z-index: 1; */ opacity: 1; margin-top: 2rem; height: 30rem; border-radius: 8px; } .firstPostContainer{ margin-top: -1rem; display: flex; padding: 0 1.5rem; } } @media (min-width: 1240px){ .image-div{ height: 28rem; } .first-post{ height: 28rem; } .firstPostIntro{ padding-bottom: 0; } } .first-post h2{ font-size: 2.5rem; padding-left: 1.4rem; cursor: pointer; font-family: 'Rubik', sans-serif; } .post-intro{ margin: -1.3rem 0 1.2rem; font-size: .9rem; padding: .2rem 1rem .2rem 1rem; font-family: 'Oxygen', sans-serif; } .avatar{ width: 40px; height: 40px; border-radius: 50%; margin-right: 0.5rem; } .tag{ color: #ff7a18; padding-left: 1.2rem; } .author-info{ display: flex; margin-left: .9rem; } .author{ font-family: 'Rubik', sans-serif; font-weight: 800; } .author-info p{ padding-left: .5rem; font-size: .9rem; font-family: 'Oxygen', sans-serif; } .date{ padding-left: .4rem; } .card1{ border: 2px solid black; margin-bottom: 1.4rem; box-sizing: border-box; width: max-content; padding: 0 .3rem; height: 8rem; } /* aside posts */ .mini-card { width: 14em; height: 18em; box-shadow: -2rem 0 3rem -2rem #000; padding: 1.5rem; border-radius: 16px; background: linear-gradient(85deg,#434343,#262626); color: #fff; position: relative; /* transition-property: transform; */ transition-duration: .5s; transform: none; } .module { margin: 1rem -2rem 2rem; } .mini-card h4{ font-size: .9rem; cursor: pointer; font-family: 'MD Primer Bold', Rubik, Lato, 'Lucida Grande', 'Lucida Sans Unicode',Tahoma,Sans-Serif; } .mini-card:hover{ transform: rotate(12deg); margin-right: 1.2rem; } .firstFourAvatar{ position: absolute; bottom: 0; } .firstFourAvatar p{ font-family: 'Rubik', sans-serif; font-weight: 500; font-size: .8rem; } .aside-post-container{ display: flex; margin-left: 4rem; } .aside-tag{ color: #ff7a18; font-size: .6rem; font-family: 'Oxygen', sans-serif; font-weight: 500; } .mini-card h3{ font-size: 1rem; cursor: pointer; } .article-date{ font-size: .7rem; font-family: 'Oxygen', sans-serif; } .card{ border: 2px solid black; margin-bottom: 1.4rem; box-sizing: border-box; width: 97%; /* height: 80%; */ padding: 0 .3rem; word-wrap: break-word; border-radius: 15px; margin: auto; margin-top: 2rem; background: #fff; } .card h3{ font-size: 1.3rem; padding-left: 1.4rem; cursor: pointer; font-family: 'MD Primer Bold', Rubik, Lato, 'Lucida Grande', 'Lucida Sans Unicode',Tahoma,Sans-Serif; font-weight: 600; } @media (min-width: 711px){ .card{ width: 40%; } .card-container{ display: flex; flex-direction: row; flex-wrap: wrap; justify-content: space-between; } } .container .tag{ color: #ffdfc7; font-family: 'Oxygen', sans-serif; margin-top: -1.1rem; } .container .title{ color: #fff; margin-top: -.9rem; margin-left: 1rem; font-size: 2rem; width: 90%; font-family: 'Rubik', sans-serif; } .author-bio{ color: #fff; margin-top: 1.5rem; } .post-content{ border: 1px solid #fff; width: 90%; border-radius: 16px; background-color: #fff; margin: auto; word-wrap: break-word; padding-bottom: 2rem; margin-bottom: 3rem; padding: .6rem; font-size: .9rem; font-family: 'Oxygen', sans-serif; } .text-prop{ padding: .4rem 1rem; } .article-sponsor{ padding: .5rem 1.5rem calc(1rem + 10px); border-radius: 8px; background: rgba(0,0,0,.2); color: rgb(235, 227, 227); width: 95%; margin: auto; margin-bottom: -1.4rem; margin-top: 1rem; } .archive .button{ margin-top: 3rem; font-family: MD Primer Bold,Rubik,Lato,Lucida Grande,Lucida Sans Unicode,Tahoma,Sans-Serif; font-style: normal; background-color: #5e5e5e; color: #fff; border: 0; border-bottom: 1px solid #262626; border-right: 1px solid #262626; box-shadow: inset 1px 1px 0 #777, 0 2px 3px rgb(0 0 0 / 40%); display: inline-block; font-weight: 700; line-height: 1.4; text-transform: uppercase; text-decoration: none; border-radius: 4px; white-space: nowrap; padding: 1rem 1.5rem; text-align: center; transition: .07s; position: relative; width: 65%; margin-left: 1.5rem; margin-bottom: 2rem; } /* footer */ .footer-container{ font-family: 'Oxygen', sans-serif; background: #111111; margin-top: 2rem; margin: -.5rem -.5rem 0 -.5rem; } .footer-container h5{ font-family: 'Oxygen', sans-serif; color: #ff7a18 } .footer-elements-container{ color: #a9a39f; margin: 0 1rem; display: flex; justify-content: space-between; flex-wrap: wrap; } .container4{ padding-top: 2.7rem; } .poweredby{ color: #fff; padding-top: 3rem; padding-left: 1.5rem; font-size: 1.5rem; padding-bottom: 2.5rem; box-sizing: border-box; margin-top: 6rem; } .move{ margin-right: 4rem; } .footer-container span{ color: #0089c7; } @media (min-width: 952px){ .poweredby{ width: 30%; box-sizing: border-box; } } @media (min-width: 1200px){ .container .title{ font-size: 3.5rem; width: 70%; word-wrap: break-word; } } .firstPostAuthorInfo{ display: flex; margin-left: .9rem; position: absolute; bottom: 0; } .firstPostAuthorInfo p{ padding-left: .5rem; font-size: .9rem; } .firstPostIntro{ padding-bottom: 2rem; }

Summary

  • With Webiny Headless CMS for the back-end project and we created the content model for the blog (CSS Tricks).
  • We then fetched the data from the Headless CMS to the Next.js project using graphql-request.
  • Created styling for the project and ensured that it looks similar to the original CSS Tricks

Yay! You did it 🚀.

Full source code: https://github.com/webiny/write-with-webiny/tree/main/tutorials/css-tricks-nextjs


This article was written by a contributor to the Write with Webiny program. Would you like to write a technical article like this and get paid to do so? Check out the Write with Webiny GitHub repo.

Find more articles on the topic of:nextjsbuild projectscss trickscontributed articles

About Webiny

Webiny is an open source serverless CMS that offers you all the enterprise-grade functionalities, while keeping your data within the security perimeter of your own infrastructure.

Learn More

Newsletter

Want to get more great articles like this one in your inbox. We only send one newsletter a week, don't spam, nor share your data with 3rd parties.

Webiny Inc © 2024
Email
  • We send one newsletter a week.
  • Contains only Webiny relevant content.
  • Your email is not shared with any 3rd parties.
Webiny Inc © 2024
By using this website you agree to our privacy policy
Webiny Chat

Find us on Slack

Webiny Community Slack