Setting SEO Titles, Meta Tags & Open Graph in Next.js

by Sachin 4 minute read 11 views

Optimizing SEO titles, meta tags and Open Graph tags in Next.js improves visibility, improves click-through rates and boosts rankings in web development.

Key Points

  • 75% of users click results from the first page—SEO tags improve your ranking position.
  • Custom metadata improves CTR by up to 30% in website development services and blog content.
  • Social shares increase 2x when Open Graph tags are used effectively by a custom website developer.

Introduction

Implementing seo in your Next.js application is essential for improving search visibility, driving organic traffic, and enhancing your site's appearance on social media platforms. In this guide, we'll create a dynamic blog system that supports full SEO integration, including dynamic meta titles, descriptions, keywords, and Open Graph tags. This tutorial is highly useful for businesses offering website development services or those looking to hire SEO experts to optimize content dynamically.

Set up Project and Create blogs Table

Start by defining your blog model using Prisma for a MySQL database. This schema will hold all essential SEO fields such as metaTitle, metaDescription, metaKeyWords, slug, and other attributes necessary for both search engines and Open Graph support. The model is shown below:

                                        model blogs {
    id               Int       @id @default(autoincrement())
    title            String    @db.VarChar(255)
    shortDescription String    @db.MediumText
    blogImage        String
    description      String    @db.LongText
    slug             String    @db.VarChar(255)
    metaTitle        String    @db.VarChar(255)
    metaDescription  String    @db.LongText
    metaKeyWords     String    @db.LongText
    publishDate      DateTime
    authorName       String
    authorImage      String
    createdAt        DateTime  @default(now())
    updatedAt        DateTime  @updatedAt
    deletedAt        DateTime?
                                    

This is foundational for those in custom web development. Ensure your Prisma version and database are compatible.

Create a Function to Convert Title to Slug (SEO-Friendly URL)

A slug is an important part of URL optimization. It ensures that your URLs are human-readable and keyword-rich, helping search engines better understand the context of your content. Below is a simple utility function to create slugs from blog titles:

                                        export default function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, "")     // Remove non-word characters
    .replace(/\s+/g, "-")         // Replace spaces with dashes
    .replace(/--+/g, "-");        // Replace multiple dashes
}
                                    

Custom website developers may choose to extend this with libraries like npm slugify for locale support.

Create an API to Add a Blog with an SEO-ready URL

This Express.js API will allow you to post a blog entry with a slug and all required metadata for SEO and Open Graph. It includes fields like title, description, meta tags, and publish date.

                                        import { NextFunction, Request, Response } from "express";
import asyncHandler from "express-async-handler";
import slugify from "../lib/slugify";
import prisma from "../lib/prisma";


export const addBlog = asyncHandler(
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const {
        title,
        shortDescription,
        description,
        metaTitle,
        metaDescription,
        metaKeyWords,
        publishDate,
        authorName,
      } = req.body;


      if (!title || !description || !publishDate || !authorName) {
        res.status(400).json({
          success: false,
          message: "Required fields are missing.",
        });
        return;
      }


      const slug = slugify(title);


      // Check if the slug already exists
      const existingBlog = await prisma.blogs.findUnique({
        where: { slug },
      });


      if (existingBlog) {
        res.status(400).json({
          success: false,
          message: "Blog already exists with the same title.",
        });
        return;
      }


      // File upload handling
      const files = req.files as {
        [fieldname: string]: Express.Multer.File[];
      };


      const blogImage = files?.blogImage?.[0];
      const authorImage = files?.authorImage?.[0];


      const newBlog = await prisma.blogs.create({
        data: {
          title,
          shortDescription,
          description,
          slug,
          metaTitle,
          metaDescription,
          metaKeyWords,
          authorName,
          blogImage: blogImage?.filename || "",
          authorImage: authorImage?.filename || "",
          publishDate: new Date(publishDate),
        },
      });


      res.status(201).json({
        success: true,
        message: "Blog created successfully.",
        data: newBlog,
      });
      return;
    } catch (error) {
      next(error);
    }
  }
);
                                    

This is essential for any custom website developer building CMS or blog platforms.

Create an Add Blog Form in Next.js

The frontend form in Next.js uses React Hook Form and React Quill for WYSIWYG editing. It allows content managers to input meta tags and submit rich content. This supports scalable publishing workflows.

                                        "use client";


import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import dynamic from "next/dynamic";
import "react-quill-new/dist/quill.snow.css";
import axios from "axios";
import InputWithLabel from "@/components/ui/inputWithLabel";
import TextareaWithLimit from "@/components/ui/TextAreaWithLimit";
import { AddBlogInput, AddBlogSchema } from "@/validations/blogsSchema";


const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false });


const AddBlogForm = () => {
  const {
    register,
    handleSubmit,
    reset,
    setValue,
    watch,
    formState: { errors },
  } = useForm<AddBlogInput>({
    resolver: zodResolver(AddBlogSchema),
  });


  const [loading, setLoading] = useState(false);
  const [editorValue, setEditorValue] = useState("");


  const onSubmit = async (data: AddBlogInput) => {
    const formData = new FormData();


    formData.append("title", data.title);
    formData.append("shortDescription", data.shortDescription);
    formData.append("description", editorValue);
    formData.append("metaTitle", data.metaTitle || "");
    formData.append("metaDescription", data.metaDescription || "");
    formData.append("metaKeyWords", data.metaKeyWords || "");
    formData.append("publishDate", data.publishDate);
    formData.append("authorName", data.authorName);
    if (data.blogImage?.[0]) formData.append("blogImage", data.blogImage[0]);
    if (data.authorImage?.[0])
      formData.append("authorImage", data.authorImage[0]);


    try {
      setLoading(true);
      await axios.post("/api/blogs", formData, {
        headers: { "Content-Type": "multipart/form-data" },
      });
      alert("Blog created!");
      reset();
      setEditorValue("");
    } catch (err) {
      console.error("Error:", err);
      alert("Failed to create blog");
    } finally {
      setLoading(false);
    }
  };


  return (
    <div className="w-full p-6 bg-white rounded shadow">
      <h2 className="text-2xl font-bold mb-4">Add Blog</h2>
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
        <InputWithLabel
          id="title"
          label="Title"
          placeholder="Title"
          {...register("title")}
          parentClassName="sign-input-box sign-new"
          error={errors["title"]?.message as string}
        />


        <TextareaWithLimit
          id="shortDescription"
          label="Short Description"
          maxLength={300}
          placeholder="Short Description"
          value={watch("shortDescription") || ""}
          onChange={(e) => setValue("shortDescription", e.target.value)}
          error={errors["shortDescription"]?.message as string}
        />


        <div>
          <label className="block mb-1 font-medium">Description</label>
          <ReactQuill
            value={editorValue}
            onChange={(value) => {
              setEditorValue(value);
              setValue("description", value); // Sync with react-hook-form
            }}
            theme="snow"
            className="custom-quill border rounded-lg"
          />
          {errors.description && (
            <p className="text-red-500 text-sm">{errors.description.message}</p>
          )}
        </div>


        <InputWithLabel
          id="metaTitle"
          label="Meta Title"
          placeholder="Meta Title"
          {...register("metaTitle")}
          parentClassName="sign-input-box sign-new"
          error={errors["metaTitle"]?.message as string}
        />


        <TextareaWithLimit
          id="metaDescription"
          label="Meta Description"
          maxLength={300}
          placeholder="Meta Description"
          value={watch("metaDescription") || ""}
          onChange={(e) => setValue("metaDescription", e.target.value)}
          error={errors["metaDescription"]?.message as string}
        />


        <InputWithLabel
          id="metaKeyWords"
          label="Meta Keywords"
          placeholder="Meta Keywords (comma separated)"
          {...register("metaKeyWords")}
          parentClassName="sign-input-box sign-new"
          error={errors["metaKeyWords"]?.message as string}
        />


        <InputWithLabel
          id="publishDate"
          type="date"
          label="Publish Date"
          {...register("publishDate")}
          parentClassName="sign-input-box sign-new"
          error={errors["publishDate"]?.message as string}
        />


        <InputWithLabel
          id="authorName"
          label="Author Name"
          placeholder="Author Name"
          {...register("authorName")}
          parentClassName="sign-input-box sign-new"
        />


        <InputWithLabel
          id="blogImage"
          type="file"
          label="Blog Image"
          {...register("blogImage")}
          parentClassName="sign-input-box sign-new"
        />


        <InputWithLabel
          id="authorImage"
          type="file"
          label="Author Image"
          {...register("authorImage")}
          parentClassName="sign-input-box sign-new"
        />


        <button
          type="submit"
          disabled={loading}
          className="bg-blue-600 text-white px-4 py-2 rounded"
        >
          {loading ? "Submitting..." : "Create Blog"}
        </button>
      </form>
    </div>
  );
};


export default AddBlogForm;
                                    

Create a Dynamic Route in Next.js to View Blogs

Dynamic routing enables blog pages to be served at SEO-friendly URLs like /blogs/my-title. Inside your app directory:

                                         /blogs
     / [slug]/page.tsx
                                    

This file structure allows for flexible and scalable blog rendering.

Create an API to Get Blog Data by Slug

This route pulls a specific blog post using its slug. It is used by the frontend to populate data and metadata.

                                        export const getBlogBySlug = asyncHandler(
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { slug } = req.params;
      const blog = await prisma.blogs.findUnique({
        where: {
          slug,
        },
      });


      if (!blog || blog.deletedAt !== null) {
        res.status(404).json({
          success: false,
          message: "Blog not found",
        });
        return;
      }


      res.status(200).json({
        success: true,
        message: "Blogs found",
        blog,
      });
      return;
    } catch (error) {
      next(error);
    }
  }
);
                                    

This pattern is commonly used in website development services that need robust, scalable content systems.

Update Dynamic Blog Page with SEO Tags

In this step, we use Next.js's generateMetadata method to dynamically insert meta tags and Open Graph tags. This ensures SEO best practices are applied to every blog detail page rendered by the server.

                                        import { apiUrl, imageUrl } from "@/config/url";
import { Metadata } from "next";
import React from "react";


interface BlogPageProps {
  params: Promise<{ slug: string }>;
}


async function FetchBlogDetails(slug: string) {
  try {
    const res = await fetch(`${apiUrl}/v1/blogs/details/${slug}`, {
      cache: "no-store",
    });
    if (!res.ok) throw new Error("Failed to fetch blog");
    const data = await res.json();
    return data;
  } catch (error) {
    console.log(error);
    return null;
  }
}


export async function generateMetadata({
  params,
}: BlogPageProps): Promise<Metadata> {
  const { slug } = await params;
  const blogData = await FetchBlogDetails(slug);
  const blog = blogData?.blog;
  if (!blog) return { title: "Blog Not Found" };


  return {
    title: blog.metaTitle || blog.title,
    description: blog.metaDescription,
    keywords: blog.metaKeywords || "",
    openGraph: {
      title: blog.metaTitle || blog.title,
      description: blog.metaDescription,
      images: [
        {
          url: `${imageUrl}${blog.blogImage}`, // Adjust path based on your storage
          alt: blog.title,
        },
      ],
    },
  };
}


const BlogPage = async ({ params }: BlogPageProps) => {
  const { slug } = await params;
  const blogDetails = await FetchBlogDetails(slug);


  const blog = blogDetails?.blog;


  return (
    <div className="max-w-4xl mx-auto px-4 py-10">
      <h1 className="text-3xl md:text-4xl font-bold mb-4">{blog.title}</h1>


      <div className="flex items-center space-x-4 mb-6">
        {blog.authorImage && (
          <img
            src={`${imageUrl}${blog.authorImage}`}
            alt={blog.authorName}
            width={40}
            height={40}
            className="rounded-full object-cover"
          />
        )}
        <div className="text-sm text-gray-600">
          By <strong>{blog.authorName}</strong> ·{" "}
          {new Date(blog.publishDate).toLocaleDateString()}
        </div>
      </div>


      {blog.blogImage && (
        <img
          src={`${imageUrl}${blog.blogImage}`}
          alt={blog.title}
          width={900}
          height={500}
          className="rounded-lg mb-6 w-full object-cover"
        />
      )}


      <div
        className="prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: blog.description }}
      />
    </div>
  );
};


export default BlogPage;
                                    

Final Words

By following these steps, you've created a fully SEO-optimized blog system in Next.js. If you're offering custom web development or looking to hire SEO expert, this implementation shows how scalable and search-friendly your architecture can be. Whether for client work or your own platform, mastering SEO integration with metadata and Open Graph in Next.js is a valuable skill that enhances your ability as a custom website developer.

Tech Stack & Version

Frontend

  • Next.js
  • TypeScript

 Backend

  • Node.js
  • Prisma
  • MySQL
  • API Routes

Deployment

  • Render
  • DigitalOcean
  • AWS
img

©2025Digittrix Infotech Private Limited , All rights reserved.