Step-by-Step Guide: Building a Blog Website Using Meteor and Hygraph

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:

  • Meteor installed
  • A Hygraph CMS account
  • Basic knowledge of JavaScript

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.

Bash
meteor create myapp
cd myapp
meteor run --settings settings.json

Installing 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.

Bash
meteor npm i lucide-react react-router-dom@6

Setting 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 SettingsEndpoints, 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:

JSON
{
    "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:

JavaScript
// 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:

HTML
<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.

JavaScript
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:

JavaScript
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:

JavaScript
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.