DEV Community

Cover image for Building a Blogging Site with React and PHP: A Step-by-Step Guide
M Mainul Hasan
M Mainul Hasan

Posted on • Originally published at webdevstory.com

Building a Blogging Site with React and PHP: A Step-by-Step Guide

Welcome to our comprehensive tutorial on building a React PHP Blogging Site. This step-by-step guide takes you through creating a fully functional blog using the powerful combination of React for the front end and PHP for the back end.

  • CRUD Operations

  • Like/Dislike Feature

By the end of this tutorial, I hope you will have a clear understanding of how to integrate a React frontend with a PHP backend, along with a functional blogging site you can continue to expand and customize.

Let’s start this exciting project and bring our blogging site to life!

Essential Tools for Our React PHP Blogging Site Tutorial

  • React

  • PHP

  • MySQL

  • Axios

  • Bootstrap

Environment Variables

To handle our API endpoint configurations, we use an .env file in our React PHP Blogging Platform.

REACT_APP_API_BASE_URL=http://localhost/Projects/blogging-stie/server/api
Enter fullscreen mode Exit fullscreen mode

Database Schema

Our blogging site has primarily two tables to store data: blog_posts for the blog entries and post_votes for counting likes and dislikes.

CREATE TABLE `blog_posts`
(
    `id`           INT(11) NOT NULL AUTO_INCREMENT,
    `title`        VARCHAR(255) NOT NULL,
    `author`       VARCHAR(255) NOT NULL,
    `content`      TEXT NOT NULL,
    `publish_date` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `post_votes`
(
    `id`         INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `post_id`    INT(11) NOT NULL,
    `user_ip`    VARCHAR(50) NOT NULL,
    `vote_type`  ENUM('like', 'dislike') NOT NULL,
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (`post_id`) REFERENCES `blog_posts` (`id`)
        ON DELETE CASCADE
        ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Enter fullscreen mode Exit fullscreen mode

Configuring CORS

In today’s web application, security is crucial. To enable safe cross-origin requests, we implement CORS policies in our config.php file.

Key Components of Our CORS Configuration

  • Allowed Origins

  • Allowed Headers

  • Handling Preflight Requests

// Define configuration options
$allowedOrigins = ['http://localhost:3000'];
$allowedHeaders = ['Content-Type'];

// Set headers for CORS
$origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
if (in_array($origin, $allowedOrigins)) {
    header('Access-Control-Allow-Origin: ' . $origin);
}

if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) {
    header('Access-Control-Allow-Methods: ' . implode(', ', $allowedMethods));
}

if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {
    $requestHeaders = explode(',', $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']);
    $requestHeaders = array_map('trim', $requestHeaders); // Trim whitespace from headers
    if (count(array_intersect($requestHeaders, $allowedHeaders)) == count($requestHeaders)) {
        header('Access-Control-Allow-Headers: ' . implode(', ', $allowedHeaders));
    }
}
Enter fullscreen mode Exit fullscreen mode

Database Configuration and Connection

To store and manage the data for our blogging platform, we use a MySQL database.

<?php
// Database configuration
$dbHost     = "";
$dbUsername = "";
$dbPassword = "";
$dbName     = "";

// Create database connection
$conn = new mysqli($dbHost, $dbUsername, $dbPassword, $dbName);

// Check connection
if ($conn->connect_error) {
    die("Connection failed: " . $conn->connect_error);
}
Enter fullscreen mode Exit fullscreen mode

Create Single Post

Core Features of the CreatePost Component

  • State Management with Hooks

  • Form Validation

  • Asynchronous Data Handling

  • Navigation and Feedback

import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';

function CreatePost() {
    const [title, setTitle] = useState('');
    const [content, setContent] = useState('');
    const [author, setAuthor] = useState('');
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState(''); // State for storing the error message

    const navigate = useNavigate();

    // Example validation function (extend as needed)
    const validateForm = () => {
        if (!title.trim() || !content.trim() || !author.trim()) {
            setError("Please fill in all fields.");
            return false;
        }
        // Additional validation logic here
        return true;
    };

    const handleSubmit = async (event) => {
        event.preventDefault();
        setError(''); // Reset error message on new submission
        if (!validateForm()) return; // Perform validation

        setIsLoading(true);

        try {
            const response = await axios.post(`${process.env.REACT_APP_API_BASE_URL}/create-post.php`, {
                title,
                content,
                author
            });
            console.log(response.data);
            navigate('/');
        } catch (error) {
            console.error(error);
            setError('Failed to create post. Please try again later.');
            setIsLoading(false);
        }
    };

    return (
        <div className="container mt-4">
            <h2>Create a New Post</h2>
            {error && <div className="alert alert-danger" role="alert">{error}</div>} {/* Display error message */}
            <form onSubmit={handleSubmit}>
                <div className="mb-3">
                    <label htmlFor="title" className="form-label">Title</label>
                    <input
                        type="text"
                        className="form-control"
                        id="title"
                        value={title}
                        onChange={(e) => setTitle(e.target.value)}
                        required
                    />
                </div>
                <div className="mb-3">
                    <label htmlFor="content" className="form-label">Content</label>
                    <textarea
                        className="form-control"
                        id="content"
                        rows="5"
                        value={content}
                        onChange={(e) => setContent(e.target.value)}
                        required
                    ></textarea>
                </div>
                <div className="mb-3">
                    <label htmlFor="author" className="form-label">Author</label>
                    <input
                        type="text"
                        className="form-control"
                        id="author"
                        value={author}
                        onChange={(e) => setAuthor(e.target.value)}
                        required
                    />
                </div>
                <button type="submit" className="btn btn-primary" disabled={isLoading}>
                    {isLoading ? <span><span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Creating post...</span> : 'Create Post'}
                </button>
            </form>
        </div>
    );
}

export default CreatePost;
Enter fullscreen mode Exit fullscreen mode
header('Access-Control-Allow-Headers: Content-Type'); // Allow Content-Type header

require_once('../config/config.php');
require_once('../config/database.php');

// Retrieve the request body as a string
$request_body = file_get_contents('php://input');

// Decode the JSON data into a PHP array
$data = json_decode($request_body, true);

// Validate input fields with basic validation
if (empty($data['title']) || empty($data['content']) || empty($data['author'])) {
    http_response_code(400);
    echo json_encode(['message' => 'Error: Missing or empty required parameter']);
    exit();
}

// Validate input fields
if (!isset($data['title']) || !isset($data['content']) || !isset($data['author'])) {
    http_response_code(400);
    die(json_encode(['message' => 'Error: Missing required parameter']));
}

// Sanitize input
$title = filter_var($data['title'], FILTER_SANITIZE_STRING);
$author = filter_var($data['author'], FILTER_SANITIZE_STRING);
$content = filter_var($data['content'], FILTER_SANITIZE_STRING);

// Prepare statement
$stmt = $conn->prepare('INSERT INTO blog_posts (title, content, author) VALUES (?, ?, ?)');
$stmt->bind_param('sss', $title, $content, $author);

// Execute statement
if ($stmt->execute()) {
    // Get the ID of the newly created post
    $id = $stmt->insert_id;

    // Return success response
    http_response_code(201);
    echo json_encode(['message' => 'Post created successfully', 'id' => $id]);
} else {
    // Return error response with more detail if possible
    http_response_code(500);
    echo json_encode(['message' => 'Error creating post: ' . $stmt->error]);
}

// Close statement and connection
$stmt->close();
$conn->close();
Enter fullscreen mode Exit fullscreen mode

Display All Posts

import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';

function PostList() {
    const [posts, setPosts] = useState([]);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState('');
    const [currentPage, setCurrentPage] = useState(1);
    const [totalPosts, setTotalPosts] = useState(0);
    const postsPerPage = 10;

    useEffect(() => {
        const fetchPosts = async () => {
            setIsLoading(true);
            try {
                const response = await axios.get(`${process.env.REACT_APP_API_BASE_URL}/posts.php?page=${currentPage}`);
                setPosts(response.data.posts);
                setTotalPosts(response.data.totalPosts);
                setIsLoading(false);
            } catch (error) {
                console.error(error);
                setError('Failed to load posts.');
                setIsLoading(false);
            }
        };

        fetchPosts();
    }, [currentPage]);

    const totalPages = Math.ceil(totalPosts / postsPerPage);
    const goToPreviousPage = () => setCurrentPage(currentPage - 1);
    const goToNextPage = () => setCurrentPage(currentPage + 1);

    return (
        <div className="container mt-5">
            <h2 className="mb-4">All Posts</h2>
            {error && <div className="alert alert-danger">{error}</div>}
            <div className="row">
                {isLoading ? (
                    <p>Loading posts...</p>
                ) : posts.length ? (
                    posts.map(post => (
                        <div className="col-md-6" key={post.id}>
                            <div className="card mb-4">
                                <div className="card-body">
                                    <h5 className="card-title">{post.title}</h5>
                                    <p className="card-text">By {post.author} on {new Date(post.publish_date).toLocaleDateString()}</p>
                                    <Link to={`/post/${post.id}`} className="btn btn-primary">Read More</Link>
                                </div>
                            </div>
                        </div>
                    ))
                ) : (
                    <p>No posts available.</p>
                )}
            </div>
            <nav aria-label="Page navigation">
                <ul className="pagination">
                    <li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
                        <button className="page-link" onClick={goToPreviousPage}>Previous</button>
                    </li>
                    {Array.from({ length: totalPages }, (_, index) => (
                        <li key={index} className={`page-item ${index + 1 === currentPage ? 'active' : ''}`}>
                            <button className="page-link" onClick={() => setCurrentPage(index + 1)}>{index + 1}</button>
                        </li>
                    ))}
                    <li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
                        <button className="page-link" onClick={goToNextPage}>Next</button>
                    </li>
                </ul>
            </nav>
        </div>
    );
}

export default PostList;
Enter fullscreen mode Exit fullscreen mode
// Load configuration files
require_once('../config/config.php');
require_once('../config/database.php');

header('Content-Type: application/json');

// Define configuration options
$allowedMethods = ['GET'];
$maxPostsPerPage = 10;

// Implement basic pagination
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$offset = ($page - 1) * $maxPostsPerPage;

// Query to count total posts
$countQuery = "SELECT COUNT(*) AS totalPosts FROM blog_posts";
$countResult = mysqli_query($conn, $countQuery);
$countRow = mysqli_fetch_assoc($countResult);
$totalPosts = $countRow['totalPosts'];

// Check if total posts query is successful
if (!$countResult) {
    http_response_code(500); // Internal Server Error
    echo json_encode(['message' => 'Error querying database for total posts count: ' . mysqli_error($conn)]);
    mysqli_close($conn);
    exit();
}

// Query to get all blog posts with pagination and ordering
$query = "SELECT * FROM blog_posts ORDER BY publish_date DESC LIMIT $offset, $maxPostsPerPage";
$result = mysqli_query($conn, $query);

// Check if paginated posts query is successful
if (!$result) {
    http_response_code(500); // Internal Server Error
    echo json_encode(['message' => 'Error querying database for paginated posts: ' . mysqli_error($conn)]);
    mysqli_close($conn);
    exit();
}

// Convert query result into an associative array
$posts = mysqli_fetch_all($result, MYSQLI_ASSOC);

// Check if there are posts
if (empty($posts)) {
    // No posts found, you might want to handle this case differently
    http_response_code(404); // Not Found
    echo json_encode(['message' => 'No posts found', 'totalPosts' => $totalPosts]);
} else {
    // Return JSON response including totalPosts
    echo json_encode(['posts' => $posts, 'totalPosts' => $totalPosts]);
}

// Close database connection
mysqli_close($conn);
Enter fullscreen mode Exit fullscreen mode

Single Post Display & Like/Dislike Feature

import React, { useState } from "react";
import { useParams } from "react-router-dom";
import axios from "axios";

const Post = () => {
    const { id } = useParams();
    const [post, setPost] = useState(null);
    const [likeCount, setLikeCount] = useState(0);
    const [dislikeCount, setDislikeCount] = useState(0);
    const [ipAddress, setIpAddress] = useState("");

    const fetchPost = async () => {
        try {
            const response = await axios.get(`${process.env.REACT_APP_API_BASE_URL}/post.php/${id}`);
            const post = response.data.data;
            setPost(post);
            setLikeCount(post.likes);
            setDislikeCount(post.dislikes);
        } catch (error) {
            console.log(error);
        }
    };

    const fetchIpAddress = async () => {
        try {
            const response = await axios.get("https://api.ipify.org/?format=json");
            setIpAddress(response.data.ip);
        } catch (error) {
            console.log(error);
        }
    };

    const handleLike = async () => {
        try {
            const response = await axios.post(`${process.env.REACT_APP_API_BASE_URL}/post.php/${id}/like/${ipAddress}`);
            const likes = response.data.data;
            setLikeCount(likes);
        } catch (error) {
            console.log(error);
        }
    };

    const handleDislike = async () => {
        try {
            const response = await axios.post(`${process.env.REACT_APP_API_BASE_URL}/post.php/${id}/dislike/${ipAddress}`);
            const dislikes = response.data.data;
            setDislikeCount(dislikes);
        } catch (error) {
            console.log(error);
        }
    };

    React.useEffect(() => {
        fetchPost();
        fetchIpAddress();
    }, []);

    if (!post) {
        return <div>Loading...</div>;
    }

    return (
        <div className="container my-4">
            <h1 className="mb-4">{post.title}</h1>
            <p>{post.content}</p>
            <hr />
            <div className="d-flex justify-content-between">
                <div>
                    <button className="btn btn-outline-primary me-2" onClick={handleLike}>
                        Like <span className="badge bg-primary">{likeCount}</span>
                    </button>
                    <button className="btn btn-outline-danger" onClick={handleDislike}>
                        Dislike <span className="badge bg-danger">{dislikeCount}</span>
                    </button>
                </div>
                <div>
                    <small className="text-muted">
                        Posted by {post.author} on {post.date}
                    </small>
                </div>
            </div>
        </div>
    );
};

export default Post;
Enter fullscreen mode Exit fullscreen mode
// Load configuration files
require_once('../config/config.php');
require_once('../config/database.php');

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    $requestUri = $_SERVER['REQUEST_URI'];
    $parts = explode('/', $requestUri);
    $id = end($parts);

    $query = "SELECT bp.*, 
                     (SELECT COUNT(*) FROM post_votes WHERE post_id = bp.id AND vote_type = 'like') AS numLikes,
                     (SELECT COUNT(*) FROM post_votes WHERE post_id = bp.id AND vote_type = 'dislike') AS numDislikes
              FROM blog_posts AS bp WHERE bp.id = ?";

    $stmt = $conn->prepare($query);
    $stmt->bind_param('i', $id);
    $stmt->execute();
    $result = $stmt->get_result();

    if ($result->num_rows === 1) {
        $post = $result->fetch_assoc();

        $response = [
            'status' => 'success',
            'data' => [
                'id' => $post['id'],
                'title' => $post['title'],
                'content' => $post['content'],
                'author' => $post['author'],
                'date' => date("l jS \of F Y", strtotime($post['publish_date'])),
                'likes' => $post['numLikes'],
                'dislikes' => $post['numDislikes']
            ]
        ];

        header('Content-Type: application/json');
        echo json_encode($response);
    } else {
        $response = [
            'status' => 'error',
            'message' => 'Post not found'
        ];

        header('Content-Type: application/json');
        echo json_encode($response);
    }

    $stmt->close();
    $conn->close();
}

function checkVote($conn, $postId, $ipAddress, $voteType) {
    $query = "SELECT * FROM post_votes WHERE post_id=? AND user_ip=? AND vote_type=?";
    $stmt = mysqli_prepare($conn, $query);
    mysqli_stmt_bind_param($stmt, "iss", $postId, $ipAddress, $voteType);
    mysqli_stmt_execute($stmt);
    $result = mysqli_stmt_get_result($stmt);
    return mysqli_num_rows($result) > 0;
}

function insertVote($conn, $postId, $ipAddress, $voteType) {
    if (!checkVote($conn, $postId, $ipAddress, $voteType)) {
        $query = "INSERT INTO post_votes (post_id, user_ip, vote_type) VALUES (?, ?, ?)";
        $stmt = mysqli_prepare($conn, $query);
        mysqli_stmt_bind_param($stmt, "iss", $postId, $ipAddress, $voteType);
        mysqli_stmt_execute($stmt);
        return mysqli_stmt_affected_rows($stmt) > 0;
    }
    return false;
}

function removeVote($conn, $postId, $ipAddress, $voteType) {
    if (checkVote($conn, $postId, $ipAddress, $voteType)) {
        $query = "DELETE FROM post_votes WHERE post_id=? AND user_ip=? AND vote_type=?";
        $stmt = mysqli_prepare($conn, $query);
        mysqli_stmt_bind_param($stmt, "iss", $postId, $ipAddress, $voteType);
        mysqli_stmt_execute($stmt);
        return mysqli_stmt_affected_rows($stmt) > 0;
    }
    return false;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $segments = explode('/', $_SERVER['REQUEST_URI']);
    $postId = $segments[6];
    $action = $segments[7];
    $ipAddress = $segments[8];
    $voteType = $action === 'like' ? 'like' : 'dislike';

    if (checkVote($conn, $postId, $ipAddress, $voteType)) {
        if (removeVote($conn, $postId, $ipAddress, $voteType)) {
            http_response_code(200);
            echo json_encode(['message' => ucfirst($voteType) . ' removed successfully.']);
        } else {
            http_response_code(500);
            echo json_encode(['message' => 'Failed to remove ' . $voteType . '.']);
        }
    } else {
        if (insertVote($conn, $postId, $ipAddress, $voteType)) {
            http_response_code(201);
            echo json_encode(['message' => ucfirst($voteType) . ' added successfully.']);
        } else {
            http_response_code(500);
            echo json_encode(['message' => 'Failed to add ' . $voteType . '.']);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Navbar

import React from 'react';
import { Link } from 'react-router-dom';

const Navbar = () => {
    return (
        <nav className="navbar navbar-expand-lg navbar-light bg-light">
            <div className="container-fluid">
                <Link className="navbar-brand" to="/">Blog Application</Link>
                <button
                    className="navbar-toggler"
                    type="button"
                    data-bs-toggle="collapse"
                    data-bs-target="#navbarNav"
                    aria-controls="navbarNav"
                    aria-expanded="false"
                    aria-label="Toggle navigation"
                >
                    <span className="navbar-toggler-icon"></span>
                </button>
                <div className="collapse navbar-collapse" id="navbarNav">
                    <ul className="navbar-nav">
                        <li className="nav-item">
                            <Link className="nav-link" to="/">Home</Link>
                        </li>
                        <li className="nav-item">
                            <Link className="nav-link" to="/create-post">Create Post</Link>
                        </li>
                    </ul>
                </div>
            </div>
        </nav>
    );
};

export default Navbar;
Enter fullscreen mode Exit fullscreen mode

App.js and Route

import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import './App.css';
import Navbar from './components/Navbar';
import CreatePost from './components/CreatePost';
import Post from './components/Post';
import PostList from './components/PostList';

function App() {
  return (
      <div className="App">
        <BrowserRouter>
          <Navbar />
          <Routes>
            <Route path={"/"} element={<PostList />} />
            <Route path="/create-post" element={<CreatePost />} />
            <Route path="/post/:id" element={<Post />} />
          </Routes>
        </BrowserRouter>
      </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Congratulations on completing this comprehensive guide to building a blogging site with React and PHP! Now you’ve a good idea to integrate a React frontend with a PHP backend, implementing essential features like CRUD operations and a like/dislike system.

This project not only enhances your development skills, but also serves as a solid foundation for future web applications.

Thank you for choosing this tutorial to advance your web development journey on how to create a blogging site using React and PHP.

Get the full React and PHP tutorial for a blogging platform on Code on GitHub.

Support Our Tech Insights

Buy Me A Coffee

Donate via PayPal button

Read Next...

Top comments (0)