A slugify function is valuable for creating clean, readable, and SEO-friendly URLs that enhance user experience and site management. To round up the service offering, we add a Word/Character count feature as well.
Here is the GitHub repo with the full code, where you can see the two new components.
Also included in the same GitHub repo is the logic for the
Broken Link Check based on the sitemap.xml
Example page hosted on Vercel -> https://nextjs14-azureb2c-prisma.vercel.app/
A slugify function is useful for several reasons, particularly in web development and content management:
URL Optimization: Slugs are user-friendly, readable, and SEO-friendly versions of URLs. They replace spaces and special characters with hyphens or other suitable characters, making URLs clean and easily read. This enhances search engine optimization (SEO) by including keywords in the URL.
Consistency: Slugify functions ensure consistent formatting for URLs, titles, and identifiers across a website. This consistency improves the user experience and makes linking to and sharing content easier.
Readability: Slugs are more readable for users than raw strings or IDs. For example, a URL with a slug-like/how-to-train-your-dragon is much easier to understand and remember than one with a query string like /?id=12345.
Prevention of Errors: By converting complex strings to slugs, the function reduces the chances of URL errors, such as spaces, special characters, or case sensitivity issues that might cause broken links or incorrect routing.
Localization: Slugify functions can be adapted to handle multiple languages, converting characters appropriately for different locales. This ensures that URLs remain readable and relevant to a global audience.
Dynamic Content: For dynamically generated content, such as blog posts or product pages, slugs provide a mechanism to create meaningful and descriptive URLs automatically based on titles or other attributes.
New Components for Slugify and Word/Character Count
Let’s create two new components and begin with the Word/Character count logic.
Word/Character Count Component
// components/tools/wordcount.component.tsx
"use client";
import React, { useState } from "react";
const WordCount: React.FC = () => {
const [text, setText] = useState("");
const [wordCount, setWordCount] = useState(0);
const [charCount, setCharCount] = useState(0);
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const inputText = event.target.value;
setText(inputText);
const count = inputText.trim().split(/\s+/).filter(Boolean).length;
const charCount = inputText.length;
const digits = inputText.trim().match(/[^ ]/g); // Exclude spaces
const digitCount = digits?.length || 0;
setWordCount(count);
setCharCount(digitCount);
};
return (
<div className="max-w-md mt-4">
<h2 className="text-2xl font-bold mb-4">Word Count Service</h2>
<textarea
className="w-full p-2 border text-base rounded mb-4"
rows={10}
value={text}
onChange={handleChange}
placeholder="Type or paste your text here..."
></textarea>
<div className="text-lg">
Word Count: <span className="font-semibold">{wordCount}</span> - Char
Count without Spaces: <span className="font-semibold">{charCount}</span>
</div>
</div>
);
};
export default WordCount;Slugify Component
Now we create the slugify component
// components/slugify/slugify.component.tsx
"use client";
import React, { useState } from "react";
// Utility function to slugify a string
const slugify = (text: string): string => {
return text
.toString()
.toLowerCase()
.replace(/\s+/g, "-") // Replace spaces with -
.replace(/[^\w-]+/g, "") // Remove all non-word chars
.replace(/--+/g, "-") // Replace multiple - with single -
.replace(/^-+/, "") // Trim - from start of text
.replace(/-+$/, ""); // Trim - from end of text
};
const Slugify: React.FC = () => {
const [inputText, setInputText] = useState<string>("");
const [slugifiedText, setSlugifiedText] = useState<string>("");
const [copySuccess, setCopySuccess] = useState<string>("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setInputText(text);
setSlugifiedText(slugify(text));
setCopySuccess(""); // Reset copy success message on input change
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(slugifiedText);
setCopySuccess("Copied!");
} catch (err) {
setCopySuccess("Failed to copy!");
}
};
return (
<div>
<h2 className="text-3xl font-bold mb-4">Slugify String</h2>
<input
type="text"
value={inputText}
onChange={handleInputChange}
className="block w-full max-w-md h-10 border border-gray-200 rounded-md pl-2 focus:border-indigo-500 focus:ring-indigo-500 sm:text-base"
placeholder="Enter text to slugify"
/>
<div className="p-4 border border-gray-300 rounded w-full max-w-md mb-6">
<p className="text-lg font-semibold">Slugified Text:</p>
<p className="text-black">{slugifiedText}</p>
<button
onClick={handleCopy}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Copy
</button>
{copySuccess && <p className="text-green-500 mt-2">{copySuccess}</p>}
</div>
</div>
);
};
export default Slugify;There is no need for an API Route or new NPM packages, so it’s quite easy.
Adding new components to page.tsx
As a last step, we add the new components to the page.tsx
import { client } from "@/lib/client";
import { notFound } from "next/navigation";
import { ArticleHero } from "@/components/contentful/ArticleHero";
import { ArticleTileGrid } from "@/components/contentful/ArticleTileGrid";
import { Container } from "@/components/contentful/container/Container";
import { draftMode } from "next/headers";
// Internationalization
import { locales, LocaleTypes } from "@/app/i18n/settings";
import { createTranslation } from "@/app/i18n/server";
//SEO - JSON-LD
import { Article, WithContext } from "schema-dts";
import path from "path";
import Script from "next/script";
import { Metadata, ResolvingMetadata } from "next";
// New Fields Part 9 of tutorial
import { PageBlogPostOrder } from "@/lib/__generated/sdk";
import { TextHighLight } from "@/components/contentful/TextHighLight";
import { revalidateDuration } from "@/utils/constants";
import { TagCloudSimpleHome } from "@/components/search/tagcloudsimpleHome.component";
import Link from "next/link";
import { LandingContent } from "@/components/contentful/ArticleContentLanding";
import Sitemapcounter from "@/components/tools/sitemapcounter/counter.component";
import WordCount from "@/components/tools/wordcount/wordcount.component";
import SitemapChecker from "@/components/tools/sitemapchecker/sitemapchecker.component";
import Slugify from "@/components/tools/slugify/slugify.component";
export const revalidate = revalidateDuration; // revalidate at most every hour
interface PageParams {
slug: string;
locale: string;
}
interface PageProps {
params: PageParams;
}
const generateUrl = (locale: string, slug: string) => {
if (locale === "en-US") {
return new URL(slug, process.env.NEXT_PUBLIC_BASE_URL!).toString();
} else {
return new URL(locale, process.env.NEXT_PUBLIC_BASE_URL!).toString();
}
};
const WebUrl = process.env.NEXT_PUBLIC_BASE_URL as string;
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const [PagedataSeo] = await Promise.all([
client.pageLanding({
slug: "/",
locale: params.locale.toString(),
preview: draftMode().isEnabled,
}),
]);
const landingPage = PagedataSeo.pageLandingCollection?.items[0];
if (!landingPage) {
return notFound();
}
const url = generateUrl(params.locale || "", "");
return {
title: landingPage.seoFields?.pageTitle,
description: landingPage.seoFields?.pageDescription,
metadataBase: new URL(WebUrl),
alternates: {
canonical: url,
languages: {
"en-US": "/",
"de-DE": "/de-DE",
"x-default": "/",
},
},
openGraph: {
type: "website",
siteName: "Example.dev - Free Tutorials and Resources for Developers",
locale: params.locale,
url: url || "",
title: landingPage.seoFields?.pageTitle || undefined,
description: landingPage.seoFields?.pageDescription || undefined,
images: landingPage.seoFields?.shareImagesCollection?.items.map(
(item) => ({
url: item?.url || "",
width: item?.width || 0,
height: item?.height || 0,
alt: item?.description || "",
type: item?.contentType || "",
})
),
},
robots: {
follow: landingPage.seoFields?.follow || false,
index: landingPage.seoFields?.index || false,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
},
},
};
}
async function Home({ params }: PageProps) {
const { isEnabled } = draftMode();
//declare JSON-LD schema
let jsonLd: WithContext<Article> = {} as WithContext<Article>;
const [landingPageData] = await Promise.all([
client.pageLanding({
slug: "/",
locale: params.locale.toString(),
preview: isEnabled,
}),
]);
const page = landingPageData.pageLandingCollection?.items[0];
if (!page) {
// If a blog post can't be found,
// tell Next.js to render a 404 page.
return notFound();
}
// TagCloud
const showTagCloud = page.showTagCloud === "Yes";
let { datanew, minSize, maxSize } = {
datanew: [],
minSize: 0,
maxSize: 0,
};
if (showTagCloud) {
const searchFacets = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/search/facets`,
{}
)
.then((res) => res.json())
.catch((error) => {
console.log("No data found");
});
if (searchFacets) {
maxSize = searchFacets.maxSize;
minSize = searchFacets.minSize;
datanew = searchFacets.datanew;
}
}
// Getting BlogPosts
const blogPostsData = await client.pageBlogPostCollection({
limit: 10,
locale: params.locale.toString(),
skip: 0,
preview: isEnabled,
order: PageBlogPostOrder.PublishedDateDesc,
where: {
slug_not: page?.featuredBlogPost?.slug,
},
});
const posts = blogPostsData.pageBlogPostCollection?.items;
// Create JSON-LD schema only if blogPost is available
if (page) {
jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: page?.seoFields?.pageTitle || undefined,
author: {
"@type": "Person",
name: page?.featuredBlogPost?.author?.name || undefined,
// The full URL must be provided, including the website's domain.
url: new URL(
path.join(
params.locale.toString() || "",
params.slug?.toString() || ""
),
process.env.NEXT_PUBLIC_BASE_URL!
).toString(),
},
publisher: {
"@type": "Organization",
name: "Example.dev - Free Tutorials and Resources for Developers",
logo: {
"@type": "ImageObject",
url: "https://www.example.dev/favicons/icon-192x192.png",
},
},
image: page?.featuredBlogPost?.featuredImage?.url || undefined,
datePublished: page.sys.firstPublishedAt,
dateModified: page.sys.publishedAt,
};
}
// Internationalization, get the translation function
const { t } = await createTranslation(params.locale as LocaleTypes, "common");
const highLightHeadings: any = page.textHighlightCollection?.items[0];
if (!page?.featuredBlogPost || !posts) return;
return (
<>
{page && (
<Script
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd),
}}
/>
)}
<Container className="mt-5">
{highLightHeadings && <TextHighLight headings={highLightHeadings} />}
{showTagCloud && datanew.length > 0 && (
<TagCloudSimpleHome
datanew={datanew}
minSize={minSize * 10}
maxSize={maxSize * 5}
locale={params.locale.toString()}
source={"homepage"}
/>
)}
<Link
href={`/${params.locale.toString()}/${page.featuredBlogPost.slug}`}
>
<ArticleHero article={page.featuredBlogPost} isHomePage={true} />
</Link>
<div className="md:mx-24 md:my-24 sm:mx-16 sm:my-16">
<LandingContent landing={page} />
</div>
</Container>
<Container className="my-8 md:mb-10 lg:mb-16">
{posts.length > 0 && (
<h2 className="mb-4 md:mb-6">{t("landingPage.latestArticles")}</h2>
)}
<ArticleTileGrid
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
articles={posts}
slug={page.featuredBlogPost.slug}
source="loadmore"
locale={params.locale.toString()}
/>
{/* SitemapCounter */}
<Sitemapcounter />
{/* SitemapChecker */}
<SitemapChecker />
{/* WordCount */}
<WordCount />
{/* Slugify */}
<Slugify />
</Container>
</>
);
}
export default Home;Here is the final result on the Homepage

Cloudapp-dev, and before you leave us
Thank you for reading until the end. Before you go:



