How I implemented slugs on Sanity - a TypeScript code sample
The lack of human-readable slugs on Sanity had bothered me for a while and I finally got around to fixing them last Sunday. The old, slugless URL structure probably wasn't doing me any favors in terms of SEO and user experience. I'm hoping the new format can give Sanity a much needed SEO boost. Plus, I can finally tell which post is which in Google Search Console and Vercel Analytics.
The Result
Before
After
https://www.sanity.media/p/64c375049f5d6b05859f10c6-delicious-post-workout-milkshake-recipe
Isn't this much clearer?
The Code
When writing the code I had the following goals in mind:
- The slugs should have no consecutive or trailing hyphens
- They should handle multiple writing systems (Cyrillic, Latin diacritics, Mandarin, etc.)
- Sanity doesn't have post titles so, when possible, the first line of text should be used for the slug
- If no slug can be computed, it should default to just using the post id
- The slugs should not be excessively long
- The code should be at least somewhat efficient since this functionality will be used a lot
It turns out that handling all of these cases is more complicated than it looks on the surface so I ended up using the github-slugger package.
The snippet below includes my entire Mongoose schema file, scroll down to the pre-save hook for the slug generation function.
import { slug } from "github-slugger";
import { Schema } from "mongoose";
import { RegularPostSchema } from "@/types";
import { MAX_POST_LENGTH, MIN_POST_LENGTH } from "@/utils";
import { PostReference, Tags, UserReference } from "./shared";
const regularPostSchema = new Schema<RegularPostSchema>(
{
content: {
type: String,
required: true,
minlength: MIN_POST_LENGTH,
maxlength: MAX_POST_LENGTH,
},
parentPost: PostReference,
parentPostAuthor: UserReference,
parentPostSlug: String,
username: {
type: String,
required: true,
},
userId: UserReference,
upvotes: {
type: [UserReference],
index: true,
},
downvotes: {
type: [UserReference],
index: true,
},
score: {
type: Number,
index: true,
default: 0,
},
commentCount: {
type: Number,
index: true,
default: 0,
},
tags: { ...Tags, index: true },
createdAt: {
type: Date,
index: true,
},
slug: {
type: String,
unique: true,
},
isPinned: Boolean,
},
{
timestamps: true,
toJSON: {
transform: (_doc, ret) => {
ret.createdAt = ret.createdAt.toISOString();
ret.updatedAt = ret.updatedAt.toISOString();
ret.postId = ret._id.toString();
if (ret.parentPost) {
ret.parentPostId = ret.parentPost.toString();
delete ret.parentPost;
}
delete ret.upvotes;
delete ret.downvotes;
delete ret._id;
delete ret.__v;
},
virtuals: true,
},
virtuals: {
upvoteCount: {
get(): number {
// @ts-ignore
return this.upvotes.length;
},
},
downvoteCount: {
get(): number {
// @ts-ignore
return this.downvotes.length;
},
},
},
},
);
const MAX_SLUG_LENGTH = 50;
const MAX_WORDS_IN_SLUG = 10;
const getFirstLineOfText = (text: string) => {
return text.split("\n")[0];
};
const generateSlug = (text: string) => {
const cleanedContent = text
.substring(0, MAX_SLUG_LENGTH * 3)
.replace(/\s+/g, " ");
const words = slug(cleanedContent)
.replace(/^(-)+/, "")
.replace(/-{2,}/g, "-")
.replace(/-+$/, "")
.split("-");
let finalSlug = "";
for (let i = 0; i < Math.min(words.length, MAX_WORDS_IN_SLUG); i++) {
const nextSlug = finalSlug ? `${finalSlug}-${words[i]}` : words[i];
if (nextSlug.length <= MAX_SLUG_LENGTH) {
finalSlug = nextSlug;
} else {
break;
}
}
return finalSlug;
};
regularPostSchema.pre("save", function (next) {
const slugFromFirstLine = generateSlug(getFirstLineOfText(this.content));
const finalSlug =
slugFromFirstLine.length === 0
? generateSlug(this.content)
: slugFromFirstLine;
this.slug = finalSlug === "" ? `${this._id}` : `${this._id}-${finalSlug}`;
next();
});
export { regularPostSchema };
If you made it this far, please consider upvoting or leaving a comment π