One of the things that are not built into Sveltekit is handling slugs for url references that are needed for Search Engine Optimization. In the following I will walk through how I implemented slug generation for my app ‘Céillí: The game of Trading‘ which pits different trading guilds against each other in a competition for champion of equity and coin trading, while also providing a platform for Equity discovery, strategy and trading. This article is intended for those already with a basic knowledge of sveltekit, primarily those migrating to this framework from other javascript and LAMP based systems.
To generate slugs often one would try to alter the ‘name’ of something such as a guild, like ‘Hansa League’ by using javascript to alter the url with dashes so that it becomes ‘hansa-league’ this is problematic in that often a user may introduce extra spaces and trying to re-convert back into a ‘name’ for lookup in the database will not accomodate for different spacing, capitlization, etc. So it is recommended to use a slug column in your database, for my project I am using Postgres db, here is the sql structure for my purposes:
CREATE TABLE guilds (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
slug text,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
To get sveltekit to pick up on this database you need to define it in the ORM you are using for my purposes I am using Drizzle, so that in drizzle schema you will need this:
// Groups table schema
export const guildsTable = pgTable("guilds", {
id: bigserial('id',{mode: 'number'}).primaryKey(),
name: text("name").notNull(),
description: text("description"),
createdAt: timestamp("created_at", { mode: "string" }).defaultNow(),
slug: text("slug").notNull().unique()
});
Additionally, to not allow users to enter extra spaces, etc if you are using zod for form validation, which is highly recommended, then you will need to put this into your zod schema:
import { z } from 'zod';
export const guildsSchema = z.object({
id: z.string().optional(),
name: z
.string({ required_error: 'title is required' })
.min(1, { message: 'title is required' })
.trim()
.refine(val => !/\s{2,}/.test(val), {
message: "No multiple consecutive spaces allowed",
}),
description: z
.string({ required_error: 'guild is required' })
.min(1, { message: 'guild is required' })
.trim()
.refine(val => !/\s{2,}/.test(val), {
message: "No multiple consecutive spaces allowed",
}),
createdAt: z.date().optional(),
});
export type GuildsSchema = typeof guildsSchema;
you will note the special validations for both ‘name’ and ‘description’ to not allow extra spaces.
To handle creating slugs I have a helper class that I put in my $lib directory:
// src/lib/generateSlugHelper.ts
import { nameToSlug } from './slugHelper'; // from earlier
import { db } from '$lib/server/db'; // however you're accessing your DB
import { guildsTable } from '$lib/server/database/drizzle-schemas'; // your actual table import
import { eq } from 'drizzle-orm'; // or your own query helper
export async function generateUniqueSlug(
name: string,
exists: (slug: string) => Promise<boolean>
): Promise<string> {
let baseSlug = nameToSlug(name);
let slug = baseSlug;
let counter = 1;
while (await exists(slug)) {
slug = `${baseSlug}-${counter++}`;
}
return slug;
}
this references slugHelper class:
// src/lib/slugHelper.ts
export function nameToSlug(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
export function slugToName(slug: string): string {
return slug
.trim()
.toLowerCase()
.replace(/-+/g, ' ')
.replace(/\b\w/g, char => char.toUpperCase());
}
so when a user goes to add a guild, there is actually minimal impact on when you call createGuild() from your schema for guild model which holds the usual controller logic for creation, deletion, updates, etc. the interactions with the slugification is done in the model file rather then in +page.server.ts or +page.svelte files. An example from my model file for createGuild() is this:
//guilds-model.ts
export const createGuild = async (guildData: Guilds, userId: string) => {
try {
console.log('Creating guild:', guildData);
// Generate a unique slug based on the name
const slug = await generateUniqueSlugForTable(guildData.name, async (slug) => {
const existing = await db
.select()
.from(guildsTable)
.where(eq(guildsTable.slug, slug));
return existing.length > 0;
});
// Insert the guild
const [guild] = await db
.insert(guildsTable)
.values({ ...guildData, slug })
.returning();
if (!guild) {
throw new Error('Guild creation failed');
}
// Insert into user_guilds as Owner
await db.insert(userGuildsTable).values({
user_id: userId,
guild_id: guild.id,
role: 'Owner',
});
return guild;
} catch (error) {
console.error('Error creating guild:', error);
throw new Error('Failed to create guild');
}
};
this references another helper class, generateUniqueSlugForTable, that references slugHelper.ts file to create the slug:
// src/lib/utils/generateUniqueSlugForTableHelper.ts
import { nameToSlug } from './slugHelper';
/**
* Generic unique slug generator for any table/column.
*
* @param name The base string to slugify (e.g. title)
* @param checkExists Function to check if a slug exists (excluding optional record ID)
* @returns A unique slug string
*/
export async function generateUniqueSlugForTable(
name: string,
checkExists: (slug: string) => Promise<boolean>
): Promise<string> {
const baseSlug = nameToSlug(name);
let slug = baseSlug;
let counter = 1;
while (await checkExists(slug)) {
slug = `${baseSlug}-${counter++}`;
}
return slug;
}
which is called by the +page.server.ts file in the process of creating a new Guild.
const newGuild = await createGuild(my_guild, user_id);
So that wherever you need to dynamically generate a link you do not reference the ‘name’ column but the slug column which is automatically updated and handled by all the slug helper classes.
Of course then an issue is what to do if the user updates or edits the name, you will need to update the slug as well which is handled again in the guilds-model.ts file, which again is the place that you handle your controller logic, so that in the +page.server.ts file when you handle updating or editing it calls this function in the models controller logic, guilds-model.ts:
export const editGuild = async (id: string, guildData: UpdateGuilds) => {
try {
// Step 1: Get the existing record
const [existingGuild] = await db
.select()
.from(guildsTable)
.where(eq(guildsTable.id, id));
if (!existingGuild) {
throw new Error(`Guild with id ${id} not found`);
}
// Step 2: Check if title has changed
let updatedSlug: string | undefined;
if (
guildData.name &&
guildData.name.trim() !== existingGuild.name.trim()
) {
// Step 3: Generate a new unique slug
updatedSlug = await generateUniqueSlugForTable(guildData.name, async (slug) => {
const existing = await db
.select()
.from(guildsTable)
.where(and(eq(guildsTable.slug, slug), ne(guildsTable.id, id))); // exclude current record
return existing.length > 0;
});
}
// Step 4: Add slug to update data if needed
const updatePayload = {
...guildData,
...(updatedSlug ? { slug: updatedSlug } : {}),
};
const result = await db
.update(guildsTable)
.set(updatePayload)
.where(eq(guildsTable.id, id))
.returning();
return result.length ? result[0] : null;
} catch (error) {
console.error(`Error updating guild with id ${id}:`, error);
throw new Error('Failed to update guild');
}
};
which again calls generateUniqueSlugForTable function in the same named helper class. And there you go, with that you can effectively deal with the creation and maintenance of seo slugs in sveltekit.
So to recap the files used, we have controller model for the database guilds, the zod schema for form validation, and three helper classes.
if you follow this methodology you will end up with three helper classes to deal with creating slugs from a field ‘name’ which is similar to ‘title’, etc:
- src/lib/generateSlugHelper.ts
- src/lib/utils/generateUniqueSlugForTableHelper.ts
- rc/lib/slugHelper.ts
Leave a Reply