Building a modern blog involves more than creating static pages; it requires managing structured content, handling dynamic data, and ensuring a seamless user experience. This guide walks you through the process of building a blog website using Meteor, a reactive full-stack JavaScript framework, and Hygraph, a headless CMS powered by a flexible GraphQL API.
In this tutorial, you’ll learn how to build a standard blog website from scratch using Meteor.js and Hygraph CMS. The tutorial covers setting up the project environment, configuring the Hygraph schema and dynamically fetching and rendering posts.
Prerequisites
To follow along with this tutorial, you need the following:
Creating a New Meteor Project
To get started, let’s create a new project from scratch using the meteor create command. Open your terminal, navigate to your desired directory, and run the command below to create and start the application.
meteor create myapp
cd myapp
meteor run --settings settings.jsonInstalling the necessary dependency
Let’s install all the dependencies required for the application to function correctly. To install the dependencies, run the command below in the terminal.
meteor npm i lucide-react react-router-dom@6Setting Up Hygraph
Now, let’s set up the Hygraph CMS blog template. To do this, log in to your Hygraph account and create a new basic blog starter project, as shown in the image below:
Next, you will be prompted to enter a project name. Name your project, then click the “Add Project” button to create it, as shown in the image below.

Retrieving Hygraph access token
For the frontend application to interact with Hygraph, you need to retrieve the project URL and access token. To get the project URL, go to your Hygraph project’s sidebar menu, navigate to Project Settings → Endpoints, and copy the “High Performance Content API” URL, as shown in the image below.

Next, click on Permanent Auth Tokens and copy the `HYGRAPH_TOKEN`, as shown in the image below.

Defining the Environment Variables
Now, let’s use Meteor settings to define the application’s environment variables. To do this, create a new file named settings.json in the root directory of the application and add the following variables:
{
"public": {
"HYGRAPH_API": "<hygraph_project_URL>"
},
"HYGRAPH_TOKEN": "<hygraph_token>"
}In the code above, replace `<hygraph_project_URL>` and `<hygraph_token>` with your corresponding Hygraph values.
Defining Application Routes
Let’s define the application routes for the home, single post, and add post pages in the App.jsx file. To do this, navigate to the imports/ui directory, open App.jsx, and replace its content with the following code:
// imports/ui/App.jsx
import React from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import { Home } from "./Home.jsx";
import { SinglePost } from "./SinglePost.jsx";
import { NewPost } from "./NewPost.jsx";
const { public: publicSettings, HYGRAPH_TOKEN } = Meteor.settings;
const HYGRAPH_API = publicSettings.HYGRAPH_API;
export const App = () => (
<BrowserRouter>
<div>
<Routes>
<Route path="/" element={<Home HYGRAPH_API={HYGRAPH_API} />} />
<Route path="/post/:id" element={<SinglePost HYGRAPH_API={HYGRAPH_API} />} />
<Route path="/new-post" element={<NewPost HYGRAPH_API={HYGRAPH_API} HYGRAPH_TOKEN={HYGRAPH_TOKEN} />} />
</Routes>
</div>
</BrowserRouter>
);Next, let’s use Bootstrap to style the application. To do this, add the Bootstrap CDN link to the main.html file. Open client/main.html and replace its content with the following:
<head>
<title>Meteor + Hygraph Blog</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
</head>
<body>
<div id="react-target"></div>
</body>Creating the Blog Homepage
Now, let’s create the application’s homepage to fetch posts from Hygraph and display them for users to browse. To do this, inside the imports/ui folder, create a new file named Home.jsx and add the following code to it.
import React, { useState, useEffect } from "react";
import { BookOpen, Calendar, User } from "lucide-react";
import { Link } from "react-router-dom";
export const Home = ({ HYGRAPH_API }) => {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => {
const fetchPosts = async () => {
try {
const query = `
{
posts(orderBy: publishedAt_DESC, first: 10) {
id
title
excerpt
publishedAt
author {
name
}
coverImage {
url
}
}
}
`;
const response = await fetch(HYGRAPH_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const json = await response.json();
if (!response.ok || json.errors) {
setErrorMsg(JSON.stringify(json.errors || json, null, 2));
return;
}
setPosts(json.data?.posts || []);
} catch (error) {
setErrorMsg(error.message);
} finally {
setLoading(false);
}
};
fetchPosts();
}, [HYGRAPH_API]);
return (
<div className="bg-light min-vh-100">
<section className="bg-primary text-white text-center py-5 mb-5 shadow-sm">
<div className="container">
<h2 className="display-6 fw-bold mb-3">Welcome to DevBlog</h2>
<p className="lead">
Explore tutorials, tips, and insights on modern web development
</p>
<Link
to="/new-post"
className="btn btn-outline-primary rounded-pill px-4 py-2 fw-semibold text-decoration-none"
style={{
borderColor: "purple",
color: "purple",
marginBottom: "10px",
}}
>
add new post
</Link>
</div>
</section>
<section className="container pb-5">
{loading && (
<div className="text-center py-5 text-muted">Loading posts...</div>
)}
{errorMsg && (
<div className="alert alert-danger text-start">
<strong>Error:</strong>
<pre className="small mt-2">{errorMsg}</pre>
</div>
)}
{!loading && !errorMsg && (
<div className="row g-3">
{posts.map((post) => (
<div key={post.id} className="col-12 col-sm-6 col-lg-4">
<div className="card h-100 shadow-sm border-0 rounded-3 overflow-hidden">
{post.coverImage?.url ? (
<img
src={post.coverImage.url}
alt={post.title}
className="card-img-top"
style={{
height: "180px",
objectFit: "cover",
marginTop: "30px",
marginBottom: "30px",
}}
/>
) : (
<div
className="bg-secondary bg-opacity-10 d-flex align-items-center justify-content-center text-muted"
style={{ height: "180px" }}
>
No Image
</div>
)}
<div className="card-body p-3">
<Link
to={`/post/${post.id}`}
className="text-decoration-none text-dark"
>
<h6 className="card-title fw-bold text-dark mb-2">
{post.title}
</h6>
</Link>
<p className="card-text text-muted small mb-3">
{post.excerpt}
</p>
</div>
<div className="card-footer bg-white border-0 d-flex justify-content-between align-items-center px-3 pb-3 small text-muted">
<span>
<User size={13} className="me-1" />
{post.author?.name || "Anonymous"}
</span>
<span>
<Calendar size={13} className="me-1" />
{new Date(post.publishedAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
))}
{posts.length === 0 && (
<div className="text-center py-5 text-muted">
No posts found.
</div>
)}
</div>
)}
</section>
<footer className="bg-dark text-white py-4 mt-auto">
<div className="container text-center">
<div className="d-flex justify-content-center align-items-center mb-2">
<BookOpen className="me-2" />
<span className="fw-bold fs-5">DevBlog</span>
</div>
<small className="text-secondary">
© 2025 DevBlog. Sharing knowledge, one post at a time.
</small>
</div>
</footer>
</div>
);
};From the code above, we make a request to fetch posts from Hygraph using the GraphQL API and display them in a styled, responsive layout with Bootstrap. Open http://localhost:3000/ in your browser, and you should see the homepage displayed as shown in the screenshot below.

Creating the Single Post Page
To allow users to read more when they click a post, we need to create a page that provides additional details about that post. To do this, inside the ui folder, create a new file named SinglePost.jsx and add the following code:
import React, { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Calendar, User, ArrowLeft, BookOpen } from "lucide-react";
export const SinglePost = ({ HYGRAPH_API }) => {
const { id } = useParams();
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => {
const fetchPost = async () => {
try {
const query = `
query GetPost($id: ID!) {
post(where: { id: $id }) {
id
title
excerpt
content {
html
}
publishedAt
author {
name
}
coverImage {
url
}
}
}
`;
const response = await fetch(HYGRAPH_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
variables: { id },
}),
});
const json = await response.json();
if (!response.ok || json.errors) {
setErrorMsg(JSON.stringify(json.errors || json, null, 2));
return;
}
setPost(json.data?.post || null);
} catch (error) {
setErrorMsg(error.message);
} finally {
setLoading(false);
}
};
fetchPost();
}, [HYGRAPH_API, id]);
if (loading) {
return <div className="text-center py-5 text-muted">Loading post...</div>;
}
if (errorMsg) {
return (
<div className="container py-5">
<div className="alert alert-danger text-start">
<strong>Error:</strong>
<pre className="small mt-2">{errorMsg}</pre>
</div>
<div className="text-center mt-4">
<Link to="/" className="btn btn-outline-primary">
<ArrowLeft size={16} className="me-1" /> Back to Home
</Link>
</div>
</div>
);
}
if (!post) {
return (
<div className="text-center py-5 text-muted">
Post not found.
<div className="mt-3">
<Link to="/" className="btn btn-outline-primary">
<ArrowLeft size={16} className="me-1" /> Back to Home
</Link>
</div>
</div>
);
}
return (
<div className="bg-light min-vh-100">
<section className="bg-primary text-white text-center py-5 mb-5 shadow-sm">
<div className="container">
<h2 className="fw-bold">{post.title}</h2>
<p className="mb-0 small">
<User size={14} className="me-1" /> {post.author?.name || "Anonymous"} ·{" "}
<Calendar size={14} className="me-1 ms-1" />{" "}
{new Date(post.publishedAt).toLocaleDateString()}
</p>
</div>
</section>
<div className="container mb-5">
<div className="card shadow-sm border-0 rounded-3 overflow-hidden">
{post.coverImage?.url ? (
<img
src={post.coverImage.url}
alt={post.title}
className="card-img-top"
style={{ height: "320px", objectFit: "cover" }}
/>
) : (
<div
className="bg-secondary bg-opacity-10 d-flex align-items-center justify-content-center text-muted"
style={{ height: "320px" }}
>
No Image
</div>
)}
<div className="card-body p-4">
<p className="text-muted small mb-3">
<span className="badge bg-primary me-2">Article</span>
{post.readTime || "5 min read"}
</p>
{post.content?.html ? (
<div
className="text-muted lh-lg"
dangerouslySetInnerHTML={{ __html: post.content.html }}
/>
) : (
<p className="text-muted lh-lg">
No content available for this post.
</p>
)}
</div>
</div>
<div className="text-center mt-4">
<Link to="/" className="btn btn-outline-primary">
<ArrowLeft size={16} className="me-1" /> Back to All Posts
</Link>
</div>
</div>
<footer className="bg-dark text-white py-4 mt-auto">
<div className="container text-center">
<div className="d-flex justify-content-center align-items-center mb-2">
<BookOpen className="me-2" />
<span className="fw-bold fs-5">DevBlog</span>
</div>
<small className="text-secondary">
© 2025 DevBlog. Sharing knowledge, one post at a time.
</small>
</div>
</footer>
</div>
);
};From the code above, we retrieve the post ID from the URL, use it to fetch the post details from Hygraph, and display the post content on the application interface. From the application’s homepage, click on any post, and you will be redirected to the single post page, as shown in the screenshot below.

Adding the New Post Page
Now, let’s create a page to add a new blog post. To do this, create another file named NewPost.jsx inside the ui folder, and add the following code to it:
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { ArrowLeft, Upload, BookOpen } from "lucide-react";
export const NewPost = ({ HYGRAPH_API, HYGRAPH_TOKEN }) => {
const [title, setTitle] = useState("");
const [excerpt, setExcerpt] = useState("");
const [content, setContent] = useState("");
const [category, setCategory] = useState("React");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const makeSlug = (text) =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "");
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setMessage("");
try {
const slug = makeSlug(title);
const date = new Date().toISOString();
const createMutation = `
mutation CreatePost($title: String!, $excerpt: String!, $content: String!, $slug: String!, $date: Date!) {
createPost(
data: {
title: $title
excerpt: $excerpt
content: { html: $content }
slug: $slug
date: $date
}
) {
id
title
}
}
`;
const createResponse = await fetch(HYGRAPH_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${HYGRAPH_TOKEN}`,
},
body: JSON.stringify({
query: createMutation,
variables: { title, excerpt, content, slug, date },
}),
});
const createJson = await createResponse.json();
if (createJson.errors) {
throw new Error(createJson.errors[0].message);
}
const postId = createJson.data.createPost.id;
const publishMutation = `
mutation PublishPost($postId: ID!) {
publishPost(where: { id: $postId }, to: PUBLISHED) {
id
title
}
}
`;
await fetch(HYGRAPH_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${HYGRAPH_TOKEN}`,
},
body: JSON.stringify({
query: publishMutation,
variables: { postId },
}),
});
setMessage(`✅ Post "${createJson.data.createPost.title}" published successfully!`);
setTitle("");
setExcerpt("");
setContent("");
setCategory("React");
} catch (error) {
setMessage(`❌ Error: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="bg-light min-vh-100 d-flex flex-column">
<section className="bg-primary text-white text-center py-4 mb-4 shadow-sm">
<div className="container">
<h2 className="fw-bold mb-0">Create New Post</h2>
</div>
</section>
<div className="container flex-grow-1">
<div className="card shadow-sm border-0 mb-5">
<div className="card-body p-4">
{message && (
<div
className={`alert ${
message.startsWith("✅") ? "alert-success" : "alert-danger"
}`}
>
{message}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label fw-semibold">Post Title</label>
<input
type="text"
className="form-control"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter your post title"
required
/>
</div>
<div className="mb-3">
<label className="form-label fw-semibold">Excerpt</label>
<input
type="text"
className="form-control"
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder="Short summary of your post"
required
/>
</div>
<div className="mb-3">
<label className="form-label fw-semibold">Category</label>
<select
className="form-select"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="React">React</option>
<option value="CSS">CSS</option>
<option value="JavaScript">JavaScript</option>
<option value="Accessibility">Accessibility</option>
</select>
</div>
<div className="mb-4">
<label className="form-label fw-semibold">Post Content</label>
<textarea
className="form-control"
rows="6"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your full post here..."
required
></textarea>
</div>
<div className="d-flex justify-content-between align-items-center">
<Link to="/" className="btn btn-outline-secondary">
<ArrowLeft size={16} className="me-1" /> Back
</Link>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? (
<>
<Upload size={16} className="me-1" /> Publishing...
</>
) : (
<>
<Upload size={16} className="me-1" /> Publish Post
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
<footer className="bg-dark text-white py-4 mt-auto">
<div className="container text-center">
<div className="d-flex justify-content-center align-items-center mb-2">
<BookOpen className="me-2" />
<span className="fw-bold fs-5">DevBlog</span>
</div>
<small className="text-secondary">
© 2025 DevBlog. Sharing knowledge, one post at a time.
</small>
</div>
</footer>
</div>
);
};From the code above, we created a simple form that allows users to add a new blog post. The posted data is validated and then sent to Hygraph for storage. From the application’s home page, click “Add New Post”, and you should see the following output as shown in the screenshot below.

Conclusion
In this tutorial, you learned how to build a functional blog website using Meteor and Hygraph. You set up the development environment, configured the Hygraph schema, integrated it with Meteor, and implemented dynamic post rendering.