Hacker News Clone with ChakraUI and React Query
This tutorial aims to show how to build a hacker news clone which is the YCombinator news website.
The tools we plan on using are Typescript, React, Axios, Chakra UI and React Query. Chakra UI is a simple, modular and accessible component library that gives the building blocks you need to build your React applications. First of all, let’s discuss what a component library is. A component library is a collection of component that serve a unified purpose or helps in creating a user interface.
Examples of Component Libraries are
- Material Components Web
- Polymer Elements
- Vaddin Web Components
- Wired Elements
- Elix
- Time Elements
- UI5-web components
- Patternfly
- Web components org
- Rebass
- Mantime
- NextUI
React Query is a server-side state management library for React which makes fetching, caching, synchronizing, and updating server state in your applications a breeze. It solves challenges that deal with server-side such as caching which is the process of storing data in a temporary location so that when the data is needed in the future it can be served faster. It also dedupes multiple requests for the same data into a single request. Data Deduplication is when you eliminate redundant data copies and reduces storage overhead.
Other benefits of React Query are
- Knowing when data is “out of date”.
- Reflecting updates to date as quickly as possible.
- Performance optimizations like pagination and lazy loading data.
- Managing memory and garbage collection of server state.
- Memoizing query results with structural sharing
You can read more about React Query on their official documentation https://tanstack.com/query/v4/docs/overview
We begin by installing Nextjs which is a framework that helps to build server-side applications in react.
Steps to take when building hacker news clone.
- Install Nextjs.
- Install Chakra UI.
- Set up the Chakra UI Root Provider.
- Install React Query.
- Change the path and directory.
- Configure tsconfig.json file.
- Create the App Layout
- Add the App.css file to style the pages.
- Import the App.css file into the pages file.
- Create the headline component.
- Implement the hook to get the paginated data.
- Add the dynamic file to get the news categories.
- Implement the footer component.
Install Nextjs
yarn create next-app --typescript
Install Chakra UI
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
Set up the Chakra UI Root provider
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ChakraProvider } from "@chakra-ui/react";
function MyApp({ Component, pageProps }: AppProps) {
return (
<ChakraProvider>
<QueryClientProvider client={queryClient.current}>
<Component {...pageProps} />
</QueryClientProvider>
</ChakraProvider>
);
}
export default MyApp;
Install React Query
yarn add @tanstack/react-query
Set up the React Query Provider in the _app.tsx file at the src/pages directory
import "../styles/App.css";
import type { AppProps } from "next/app";
import { ChakraProvider } from "@chakra-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRef } from "react";
function MyApp({ Component, pageProps }: AppProps) {
const queryClient = useRef(
new QueryClient({
defaultOptions: {
queries: {
retry: false,
useErrorBoundary: false,
refetchOnWindowFocus: false
}
}
})
);
return (
<ChakraProvider>
<QueryClientProvider client={queryClient.current}>
<Component {...pageProps} />
</QueryClientProvider>
</ChakraProvider>
);
}
export default MyApp;
Change the path and directory
Create an src folder and move the pages and styles directory into the src folder.

Configure your tsconfig.json to use src as baseUrl
Add the src as baseUrl "baseUrl": "./src" and add "@/public/*": ["public/*"], "@/components/*": ["components/*"], "@/styles/*": ["styles/*"], "@/utils/*": ["utils/*"], "@/types/*": ["types/*"], "@/hooks/*": ["hooks/*"] to the path property
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "./src",
"paths": {
"@/public/*": ["public/*"],
"@/components/*": ["components/*"],
"@/styles/*": ["styles/*"],
"@/utils/*": ["utils/*"],
"@/types/*": ["types/*"],
"@/hooks/*": ["hooks/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Create the layout that holds the app header, a section containing the lists and footer together.
Create a layout class in src/pages/layout to hold the layout of the application.

Create a folder named layout, then import the box element from chakra and react element. The box element in chakra is similar to the div element. Typescript adds types to javascript which allows us to understand the data being passed around.
import { Box } from "@chakra-ui/react";
import { ReactElement } from "react";
type ILayout = {
children?: ReactElement;
isLoading: boolean;
newsItems: INewsItem[];
isFetchingNextPage: boolean;
fetchNextPage: () => void;
};
const AppLayout = ({
children,
isLoading,
newsItems,
isFetchingNextPage,
fetchNextPage
}: ILayout) => {
return <Box></Box>;
};
export default AppLayout;The AppLayout file contains some props which are
- isLoading,
- children,
- newsItem,
- isFetchingNextPage
- fetchNextPage.
The isLoading props determines if we should show a loading state to let the user know when we are loading data from an API, the children props takes a react Element, newsItem props respresents the stories which we are going to pass the AppLayout, the isFetchingNextPage props determines when we are to show the paginated data and the fetchNextPage props gets the next paginated data.
You should see an error in the newsItems property you can solve that error by adding the newsItem types to the types folder.
Add the newsItem to the types folder

Create the NewsItem type
export interface INewsItem {
id: number;
title: string;
by: string;
descendants: number;
kids: number[];
time: number;
score: number;
type: string;
url: string;
}
Replace what is in src/pages/index.tsx with this. We are importing the AppLayout and we passed the default value as props.
import type { NextPage } from "next";
import AppLayout from "../layout";
const Home: NextPage = () => {
return (
<AppLayout
isLoading={false}
newsItems={[]}
isFetchingNextPage={false}
fetchNextPage={() => {}}
/>
);
};
export default Home;
Add app.css to the styles folder and the app.css file should contain this
.container{
/* max-width: 1050px; */
width: 80%;
margin: 5px auto 0px;
background-color: black;
}
.header{
height: 25px;
width: 100%;
background-color: #ff6600;
display: flex;
justify-content: space-between;
padding: 0px;
}
.header-section{
display: flex;
justify-content: space-between;
/* border: 1px solid;
border-color: black; */
}
.header-text-section{
display: flex;
margin: 1px;
}
.header-image{
color: hsla(16, 100%, 50%, 0.973);
margin: 2px 5px 0px 2px;
border: 1px solid;
border-color:white;
}
.header-text-section{
cursor: pointer;
}
.header-text{
font-weight: 900;
font-size: 14px;
margin: 1px 0px 0px 0px;
}
.header-menu{
margin: 1px 0px 0px 10px;
}
.menu-item{
display: inline;
font-size: 14px;
font-weight: 450;
margin: 0px 10px 0px;
cursor: pointer;
}
.login-section p{
font-size: 14px;
font-weight: 400;
margin: 3px 10px 0px 4px;
}
.lists-of-headline{
}
.headline{
display: flex;
align-items: baseline;
margin: 0px 0px 0px 5px;
}
.headline-no{
display: flex;
align-items: center;
/* border: 1px solid;
border-color: black; */
}
.headline-no p{
margin: 5px 0px 0px 0px;
color: #828282;
font-size: 13px;
font-weight: 550;
}
.grayarrow{
width: 10px;
height: 10px;
border: 0px;
margin: 5px 0px 0px 2px;
background: url("../grayarrow.gif")
}
.headline-section{
display: flex;
margin: 0px 0px 0px 0px;
align-items: baseline;
/* border: 1px solid;
border-color: black; */
}
.headline-content{
margin: 0px 0px 0px 2px;
}
.main-headline{
margin: 5px 0px 0px 0px;
font-size: 14.5px;
font-weight: 430;
}
.headline-website{
margin: 0px 0px 0px 3px;
font-size: 12px;
color: #a38f87;
}
.headline-comments{
margin: -8px 0px 0px 0px;
/* display: inline-block;
border: 1px solid;
border-color: black; */
}
.headline-comments p{
margin: 0px 5px 0px 0px;
font-size: 0.69rem;
color: #a38f87;
display: inline-block;
/* border: 1px solid;
border-color: #b3918271; */
}
.link-style{
text-decoration: none;
color: #090402;
}
@media screen and (max-width: 700px){
.header{
height: 50px;
}
.header-text-section{
display: block;
}
.header-image{
margin-top: 10px;
}
.header-menu{
margin: 0px 0px 0px 0px;
}
.login-section p{
margin-top: 10px;
}
}
For CSS file to work in nextjs you have to import the app.css file in _app.tsx file.
import "./../styles/globals.css";
import "../styles/App.css";
import type { AppProps } from "next/app";
import { ChakraProvider } from "@chakra-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRef } from "react";
function MyApp({ Component, pageProps }: AppProps) {
const queryClient = useRef(
new QueryClient({
defaultOptions: {
queries: {
retry: false,
useErrorBoundary: false,
refetchOnWindowFocus: false
}
}
})
);
return (
<ChakraProvider>
<QueryClientProvider client={queryClient.current}>
<Component {...pageProps} />
</QueryClientProvider>
</ChakraProvider>
);
}
export default MyApp;
Create the app header in the App Layout.
Add the header section for hacker news and it should contain the hacker news image, the title, and the categories showing the header section.
import { Box, Image, Text } from "@chakra-ui/react";
import { useRouter } from "next/router";
import { ReactElement } from "react";
import { INewsItem } from "../types";
type ILayout = {
children?: ReactElement;
isLoading: boolean;
newsItems: INewsItem[];
isFetchingNextPage: boolean;
fetchNextPage: () => void;
};
const AppLayout = ({
children,
isLoading,
newsItems,
isFetchingNextPage,
fetchNextPage
}: ILayout) => {
const menuHeader = ["news", "ask", "show", "jobs"];
const router = useRouter();
function goToPage(page: string) {
router.push(`${page}`);
}
return (
<Box>
<Box className="App">
<Box className="container">
<Box className="header">
<Box className="header-section">
<Box className="header-image-section">
<Image alt={"gif"} className={"header-image"} src={"y18.gif"} />
</Box>
<Box className="header-text-section">
<Box
className="header-text-content"
onClick={() => goToPage("/")}
>
<Text className="header-text">Hackers News</Text>
</Box>
<Box display={"flex"} className="header-menu">
{menuHeader.map((item: string, index: number) => (
<Box key={index} display={"flex"} alignItems={"center"}>
<Text
className="menu-item"
onClick={() => goToPage(item)}
>
{item}
</Text>
<Text m={"0px 0px 2px 0px"}>|</Text>
</Box>
))}
</Box>
</Box>
</Box>
</Box>
</Box>
</Box>
</Box>
);
};
export default AppLayout;
Your page should look like this with the header component.

Create the headline Component.
Implement the headline component to display the individual list of items.

Create the headline component and your headline component should contain the codes below.
import { Box } from "@chakra-ui/react";
import { INewsItem } from "../../types";
type props = {
newsId: number;
headline: INewsItem;
};
const Headline = ({ newsId, headline }: props) => {
let headlineNo = 1;
const getWebsiteDomain = (url: string) => {
if (url) {
let domain = url.split("/");
return `(${domain[2]})`;
}
return "";
};
return (
<Box className="headline pointer">
{}
<Box className="headline-no">
<p>{newsId}.</p>
<Box className="grayarrow" title="upvote"></Box>
</Box>
<Box className="headline-content">
<Box className="headline-section">
<a
href={headline.url}
className="main-headline link-style"
target="_blank"
rel="noreferrer"
>
{headline.title}
</a>
<p className="headline-website">{getWebsiteDomain(headline.url)}</p>
</Box>
<Box className="headline-comments">
<p>{headline.score} points</p>
<p>by {headline.by} </p>
<p>58 minutes ago</p>
<p>| hide</p>
<p>| {headline.descendants} comments</p>
</Box>
</Box>
</Box>
);
};
export default Headline;
Implement the pagination section.
Create the paginated folder

Implement the custom method to get paginated data.
import api from "@/utils/api";
import { _errorHandler } from "@/utils/helpers";
import { useInfiniteQuery, QueryKey } from "@tanstack/react-query";
const onError = (err: any) => {
_errorHandler(err);
};
export const useGetCustomStories = (
url: string,
queryName: string | QueryKey
) => {
let customStoriesResponse: any[] = [];
const getCustomStories = async (step: number) => {
const storiesresponse = await api.get(`${url}.json?print=pretty`);
let storiesresponseID = storiesresponse.data;
for (let i = 0; i < step; i++) {
const response = await api.get(
`/item/${storiesresponseID[i]}.json?print=pretty`
);
customStoriesResponse[i] = response.data;
}
return customStoriesResponse;
};
const { data, isLoading, fetchNextPage, isFetchingNextPage, hasNextPage } =
useInfiniteQuery(
[queryName],
({ pageParam = 30 }) => getCustomStories(pageParam),
{
onError,
getNextPageParam: (lastPage, pages) => {
return lastPage ? pages.length + 30 : null;
}
}
);
let customStories: any[] = [];
data?.pages.map((page) => page.map((item: any) => customStories.push(item)));
return {
customStories,
isLoading,
fetchNextPage,
isFetchingNextPage,
hasNextPage
};
};
Add the utils folder

Create an helper method in the utils folder
This utils folder is meant to hold the helper method to handle error.
import { AxiosError } from "axios";
import toast from "react-hot-toast";
export const _errorHandler = (err: unknown) => {
let error: any = err as AxiosError;
let message = !navigator.onLine
? "Please check your internet connection"
: error?.response.status == 400
? "Something went wrong"
: "";
return toast.error(message);
};
const onError = (err: any) => {
_errorHandler(err);
};Install the necessary dependencies
We need the axios library to make HTTP requests from the browser to access the API.
yarn add axios react-hot-toast
Create a folder that holds the base API

Add this to the src/utils/api/index.ts
import axios from "axios";
export const api = axios.create({
baseURL: `https://hacker-news.firebaseio.com/v0/`
});
export default api;
Add a dynamic file to show the category path

Create a dynamic route to navigate to the pages of the categories in the AppLayout
import { useGetCustomStories } from "@/hooks/index";
import { getQueryPath } from "@/utils/helpers";
import { Box, Text } from "@chakra-ui/react";
import AppLayout from "layout";
import { useRouter } from "next/router";
const Index = () => {
const route = useRouter();
const category_path = route.query.category_path;
const { url, queryKey } = getQueryPath(category_path)!!;
const { isLoading, customStories, fetchNextPage, isFetchingNextPage } =
useGetCustomStories(url, queryKey);
return (
<AppLayout
isLoading={isLoading}
newsItems={customStories}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
);
};
export default Index;
Add this to the src/utils/helpers.tsx
export const getQueryPath = (path: string | string[] | undefined) => {
switch (path) {
case "news": {
return setUrlAndQueryKey("topstories", "news");
}
case "ask": {
return setUrlAndQueryKey("askstories", "ask");
}
case "show": {
return setUrlAndQueryKey("showstories", "show");
}
case "jobs": {
return setUrlAndQueryKey("jobstories", "job");
}
default:
return setUrlAndQueryKey("topstories", "news");
break;
}
};
export function setUrlAndQueryKey(url: string, queryKey: QueryKey | string) {
return { url, queryKey };
}
The site should look like this

Implement the paginated section to the layout section
The isFetchingNextPage props provide the condition to show the loading text or the more text to fetch more data to display to the user.
{isFetchingNextPage ? (
<Text margin={"15px 0px 15px 0px"} textAlign={"center"}>
isLoading...
</Text>
) : (
<Text
m={"10px 0px 10px 40px"}
onClick={increaseStep}
cursor={"pointer"}
fontSize={"14px"}
color={"#828282"}
>
More
</Text>
)}Add type of the props passed to the footer to the types file.
export interface IFooterData {
text: string;
link: string;
}
Implement the footer section.
We then need to create a footer data variable to hold the name and url to each footer section.
import { IFooterData } from "../types";
export const footerData: IFooterData[] = [
{
text: "Guidelines",
link: "https://news.ycombinator.com/newsguidelines.html"
},
{
text: "FAQ",
link: "https://news.ycombinator.com/newsfaq.html"
},
{
text: "Lists",
link: "https://news.ycombinator.com/lists"
},
{
text: "API",
link: "https://github.com/HackerNews/API"
},
{
text: "Security",
link: "https://news.ycombinator.com/security.html"
},
{
text: "Legal",
link: "https://www.ycombinator.com/legal/"
},
{
text: "Apply to YC",
link: "http://www.ycombinator.com/apply/"
}
]Create the footer component
import { IFooterData } from "@/types/index";
import { Input, Box, Text } from "@chakra-ui/react";
import Link from "next/link";
interface props {
footerLink: IFooterData[];
}
function Footer({ footerLink: props }: props) {
return (
<Box>
<Box borderTop={"2px solid #ff6600"} />
<Box maxWidth={"469px"} margin={"10px auto 0px"} pb={"20px"}>
<Link href={"https://www.ycombinator.com/apply/"}>
<Text
cursor={"pointer"}
fontSize={"15px"}
textAlign={"center"}
fontWeight={"400"}
>
Applications are open for YC Winter 2023
</Text>
</Link>
<Box display={"flex"} margin={"auto"} alignItems={"center"}>
{props.map((item: IFooterData, index: number) => (
<Box
display={"flex"}
alignItems={"center"}
key={index}
// border={"1px solid"}
>
<Link href={item.link}>
<Text cursor={"pointer"} fontSize={"11px"} margin={"10px"}>
{item.text}
</Text>
</Link>
<Box borderRight={"1px solid"} height={"13px"} />
</Box>
))}
<Link href={"hn@ycombinator.com"}>
<Text margin={"10px"} cursor={"pointer"} fontSize={"11px"}>
Contact
</Text>
</Link>
</Box>
<Box
maxWidth={"200px"}
margin={"5px auto 0px"}
display={"flex"}
alignItems={"center"}
>
<Text fontSize={"15px"} color={"#828282"} fontWeight={"500"}>
Search:{" "}
</Text>
<Input
type={"name"}
height={"21.5px"}
width={"148px"}
ml={"5px"}
background={"white"}
borderRadius={"1px"}
border={"1px solid black"}
borderColor={"black"}
/>
</Box>
</Box>
</Box>
);
}
export default Footer;
Add the footer component to app layout after the paginated component.
<Footer footerLink={footerData} />We are going to add the custom stories and import the app layout to the pages index file
import { useGetCustomStories } from "../hooks";
import AppLayout from "../layout";
const Home = () => {
const { isLoading, customStories, fetchNextPage, isFetchingNextPage } =
useGetCustomStories("topstories", "news");
return (
<AppLayout
isLoading={isLoading}
newsItems={customStories}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
);
};
export default Home;The final application should look like this

