[ 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:
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.
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:
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.
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:
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.
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:
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:
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
Validate forms and route params — schemas for the create form and for route params.
Translate your app — negotiating the locale the formatters take.
Query D1 and Durable Object SQL — the tables behind
Posts.@sdxc/dates— day grids, week boundaries and the zone math underneath.