sdxc

Type to search, or start from one of these:

[ Building Remix apps ]

Dates, text and identifiers

Mint prefixed record IDs, slug and summarize titles, show dates in the viewer's zone, and compare client versions.

Last updated 2026-09-29

Every app handles the same handful of values: the ids its records carry, the slugs and summaries it derives from what people type, the dates it shows, and the version numbers clients send. Each is easy to get almost right, and almost right ends up as a URL that moves, a date that is off by a day for half your readers, or an id from the wrong table that finds nothing.

This guide follows one record, a blog post, through a Remix v3 app. @sdxc/uuid and @sdxc/typeid mint and check its id, @sdxc/strings derives its title, slug and summary, @sdxc/dates shows when it was published, and @sdxc/semver decides which API clients may still read it.

npm add @sdxc/uuid @sdxc/typeid @sdxc/strings @sdxc/dates @sdxc/semver \
	@sdxc/result @sdxc/validate @sdxc/http remix

Mint record ids

A bare UUID says nothing about what it identifies, so one pasted into a support ticket has to be tried against every table. A TypeID puts the answer in the string, post_01h455vb4pex5…, and keeps the UUID recoverable. Bind each prefix once, in one module:

app/ids.ts
import type { TypeID } from "@sdxc/typeid";

import { typeid } from "@sdxc/typeid";
import { generateUUIDv7 } from "@sdxc/uuid";

export const postId = typeid("post");
export const authorId = typeid("author");

export type PostId = TypeID<"post">;

export function newPostId(): PostId {
	return postId(generateUUIDv7());
}

The prefix is a literal type, so a function asking for a TypeID<"post"> refuses an author id at compile time. generateUUIDv7 puts a millisecond timestamp in front of the random bits, and a TypeID sorts the way its UUID does, so new ids arrive in creation order and a listing can page on the id alone. Use generateUUID instead for an id you hand to someone outside the system, since a v7 value reveals when it was minted.

Keep UUIDs typed below the edge

Store the UUID and show the TypeID. The column holds the 36-character UUID the database indexes well, and the prefix is added where an id leaves your app. UUID is a branded string: a plain string is not assignable to it, so a repository typed on UUID cannot be handed a raw path segment by mistake.

app/data/post.ts
import type { UUID } from "@sdxc/uuid";

import { assertUUID } from "@sdxc/uuid";

export interface Post {
	id: UUID;
	title: string;
	slug: string;
	summary: string;
	publishedAt: Date | null;
}

export function toPost(row: {
	id: string;
	title: string;
	slug: string;
	summary: string;
	published_at: number | null;
}): Post {
	assertUUID(row.id);
	return {
		id: row.id,
		title: row.title,
		slug: row.slug,
		summary: row.summary,
		publishedAt: row.published_at === null ? null : new Date(row.published_at),
	};
}

assertUUID narrows row.id for the rest of the function and throws when it is not a UUID. That fits here, where a malformed id is corrupt data rather than something a visitor typed. For input a person controls, isUUID(value) gives the same narrowing as a boolean you can branch on.

Derive the title, slug and summary

A slug typed into a form and a slug a background job derives from the same title must agree, or the published URL moves. Put the derivation in one function and call it from both:

app/posts/derive.ts
import { createTitleizer, excerpt, slugify, wordCount } from "@sdxc/strings";

const titleize = createTitleizer({ special: ["Remix", "Cloudflare", "SQLite"] });

export function derivePost(input: { title: string; body: string }) {
	let title = titleize(input.title.trim());
	return {
		title,
		slug: slugify(title),
		summary: excerpt(input.body, { length: 160 }),
		readingMinutes: Math.max(1, Math.ceil(wordCount(input.body) / 200)),
	};
}

titleize follows the Chicago Manual of Style, so "how to deploy remix on cloudflare" becomes "How to Deploy Remix on Cloudflare": small words stay lowercase, and the special list spells your product vocabulary the way you do. A built-in list already covers names like TypeScript, GitHub and JavaScript. slugify folds accents rather than dropping them, so "Cómo usar Remix" becomes como-usar-remix, and keeps letters outside Latin as letters. excerpt collapses the body onto one line and cuts at a word boundary, counting grapheme clusters, so an emoji is never split in half; it makes a good meta description. wordCount segments words with Intl.Segmenter, which also counts a script written without spaces.

Slugs are not unique on their own. Check for a collision when you insert, and append a short suffix when there is one.

Create and address the record

The create action puts the two together: a new id, the derived fields, and a redirect to the edit page addressed by the TypeID. The route table maps admin.posts.create to post("/admin/posts") and admin.posts.edit to get("/admin/posts/:id"), and Posts is your repository, which maps each row it reads through toPost.

app/http/controllers/admin/posts/create.tsx
import { redirect } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import { Posts } from "~/app/data/posts";
import { newPostId } from "~/app/ids";
import { derivePost } from "~/app/posts/derive";
import { NewPostPage } from "~/resources/views/admin/new-post";
import routes from "~/routes/web";

const NEW_POST = s.object({ title: s.string(), body: s.string() });

export default createAction(routes.admin.posts.create, async (ctx) => {
	let input = await validate(ctx.formData, NEW_POST);
	if (isFailure(input))
		return ctx.render(<NewPostPage issues={input.error.issues} />);

	let id = newPostId();
	let fields = derivePost(input.data);
	await Posts.create(ctx.db, { id: id.toUUID(), body: input.data.body, ...fields });

	let edit = routes.admin.posts.edit.href({ id: id.toString() });
	return redirect(edit, { status: redirect.Status.SeeOther });
});

toUUID() hands the database the value its column holds, typed UUID, and toString() is the post_… form for the URL. Validate forms and route params covers the schema and the error page in depth.

The edit page reads the id back. TypeID.isValid with the expected prefix answers the routing question, so an author id pasted into a post URL is a 404 at the edge instead of a query that quietly finds nothing:

app/http/controllers/admin/posts/edit.tsx
import { text } from "@sdxc/http/response";
import { TypeID } from "@sdxc/typeid";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import { Posts } from "~/app/data/posts";
import { EditPostPage } from "~/resources/views/admin/edit-post";
import routes from "~/routes/web";

export default createAction(routes.admin.posts.edit, async (ctx) => {
	let { id } = s.parse(s.object({ id: s.string() }), ctx.params);
	if (!TypeID.isValid(id, "post")) return text("Not found", { status: 404 });

	ctx.log.set({ post: { id } });
	let post = await Posts.find(ctx.db, TypeID.fromString(id, "post").toUUID());
	if (post === null) return text("Not found", { status: 404 });

	return ctx.render(<EditPostPage post={post} />);
});

The log records post_01h455vb4pex5…, which a person reading it can place at a glance. Going the other way, postId(post.id) turns a stored UUID back into the TypeID for a link.

Show dates in the viewer's zone

A post published at 02:00 UTC on the 29th was published on the 28th for a reader in New York. Formatting on the server with the server's zone gets that wrong for everyone who does not live in UTC. @sdxc/dates never assumes a zone: every function that answers a calendar question takes one, so resolve the viewer's once per request and pass it down.

app/http/viewer.ts
import type { TimeZone } from "@sdxc/dates";

export interface Viewer {
	locale: string;
	timeZone: TimeZone;
}

export function viewerOf(request: Request, saved: Partial<Viewer> = {}): Viewer {
	let cf = request.cf as IncomingRequestCfProperties | undefined;
	return {
		locale: saved.locale ?? "en-US",
		timeZone: saved.timeZone ?? cf?.timezone ?? "UTC",
	};
}

A signed-in person's saved preference wins. Otherwise Cloudflare's request.cf.timezone names the zone the request came from, which is right for most readers on the first visit. Validate a zone a person picks against Intl.supportedValuesOf("timeZone") before saving it, since Intl throws on a name it does not know. For the locale, Translate your app already negotiates one.

The page handler passes viewerOf(ctx.request) down to the component that renders the date:

resources/components/post-meta.tsx
import type { Handle } from "remix/ui";

import { diffInDays, formatDate, formatRelative } from "@sdxc/dates";
import { pluralize } from "@sdxc/strings";

import type { Viewer } from "~/app/http/viewer";

interface Props {
	publishedAt: Date;
	comments: number;
	viewer: Viewer;
}

export function PostMeta(handle: Handle<Props>) {
	return () => {
		let { publishedAt, comments, viewer } = handle.props;
		let { locale, timeZone } = viewer;
		let now = new Date();
		let when =
			diffInDays(now, publishedAt, timeZone) < 7
				? formatRelative(publishedAt, { locale, now })
				: formatDate(publishedAt, { locale, timeZone, dateStyle: "long" });

		let count = `${comments} ${pluralize("comment", comments)}`;

		return (
			<p>
				<time dateTime={publishedAt.toISOString()}>{when}</time> · {count}
			</p>
		);
	};
}

diffInDays counts the calendar days crossed in the viewer's zone, not 24-hour periods, so "yesterday" means yesterday on the reader's wall calendar. formatRelative picks the unit and lets Intl word it ("yesterday", "3 days ago", "hace 3 días"), and formatDate uses one cached Intl.DateTimeFormat per locale and options. The dateTime attribute keeps the exact instant in the markup for machines. pluralize("comment", comments) returns the singular when the count is exactly 1.

The same zone groups a list into days: toDayKey(post.publishedAt, timeZone) gives the "YYYY-MM-DD" key of the reader's calendar day, which makes a stable key for an archive heading or a count per day. Arithmetic on instants takes no zone at all, because a length of time is the same everywhere: add(new Date(), "30 days") is when a draft preview link expires.

Compare client versions

Your API also serves a mobile app that sends its version in X-Client-Version. Old builds read a field you are about to remove, so the API turns them away with a message telling the person to update:

app/http/middleware/client-version.ts
import type { Middleware } from "remix/router";

import { json } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import { parse, satisfies } from "@sdxc/semver";

const MINIMUM_CLIENT = "2.4.0";

export const requireSupportedClient: Middleware = (ctx, next) => {
	let version = ctx.request.headers.get("X-Client-Version");
	if (version === null) return next();

	if (isFailure(parse(version))) {
		return json({ error: "X-Client-Version is not a version" }, { status: 400 });
	}
	if (satisfies(version, "<", MINIMUM_CLIENT)) {
		let body = {
			error: "This version is no longer supported",
			minimum: MINIMUM_CLIENT,
		};
		return json(body, { status: 426 });
	}

	return next();
};

Versions compare element by element as numbers, so 2.10.0 is newer than 2.9.0, and a prerelease such as 2.4.0-rc.1 precedes 2.4.0. satisfies answers false whenever either side is not a version, which is why the handler parses first: without that check, a header reading nightly would pass as "not below the minimum". parse accepts a leading v, as a git tag writes it, and drops build metadata, which carries no precedence.

For a list, compare is a sort comparator: versions.sort(compare) orders a registry's answer by precedence, with anything that is not a version collected at the front, so .at(-1) is the newest release. satisfies(version, "^", "2.0.0") expresses a compatible range when a rule lives in data an operator edits.

Where to go next