Ricaleen Metal

On this page
Ricaleen Metal is a metal trading company, and this is its product catalog and company site, in Indonesian and English. It's also the one where I built the admin myself, so staff can manage products, categories and articles on their own.
Why I didn't use a CMS
The obvious move was a A CMS that only stores and serves content through an API, and leaves the website itself to you.. I tried Payload and Medusa first, and both put me off. Before I could build anything, I'd have had to learn their docs and their syntax, and a lot more besides.
So I wrote the admin into the same Next.js app as the site. Here's what it's built on:
- Content lives in Postgres on Neon, through Drizzle.
- Images go to Vercel Blob.
- The forms use react-hook-form and Zod.
- dnd-kit lets staff drag product photos into order.
(I did learn Payload for Analis Architects.)
The translation table I dropped
Every product, category and article on the site exists twice, once in Indonesian and once in English, so the database has to hold every name and every text in both languages.
My first schema did what the textbooks say. An articles table held the article, and an article_translations table held one row per language, with a unique key on the article and the language:
export const articleTranslationsTable = pgTable(
"article_translations",
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
articleId: integer("article_id")
.notNull()
.references(() => articlesTable.id, { onDelete: "cascade" }),
title: varchar({ length: 255 }).notNull(),
language: languageEnum().notNull(), // "EN" or "ID"
content: text().notNull(),
},
(table) => [unique().on(table.articleId, table.language)]
);
Want a third language? Add rows. No schema change needed.
It lasted a day. I dropped it, along with the five A small script that changes the database's tables, run once and in order. that had built it, and folded both languages into the article row:
export const articlesTable = pgTable("articles", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
thumbnail: varchar({ length: 255 }),
titleId: varchar("title_id", { length: 255 }).notNull(),
titleEn: varchar("title_en", { length: 255 }).notNull(),
contentId: text("content_id").notNull(),
contentEn: text("content_en").notNull(),
});
All I wrote down at the time was "adjust database structure", so I can't pretend there was a grand theory behind it. What the new shape buys is easy to see, though. A product is one row, a page needs no A query that stitches rows from two tables together, here the article and its translation., and picking the language is a single line:
const title = locale === "id" ? product.titleId : product.titleEn;
The cost is just as easy to see: a third language would mean new columns. With two languages, I'll take that trade.
Products and categories have the same shape. In the admin, each form has an ID tab and an EN tab, and Zod won't let anyone save until both languages are filled in.
The language lives in the URL
Every public page sits under /id or /en, with Indonesian as the default. That's next-intl's routing, set up in a few lines:
export const routing = defineRouting({
locales: ["id", "en"],
defaultLocale: "id",
});
When someone lands on /, next-intl picks a language from a saved cookie or their browser's settings and sends them there. Interface text that isn't content, like menus and headings, comes from one small dictionary per language, with 44 strings each.
The language switcher keeps the current page and its query string. If you've narrowed the catalog down to one category and switch to English, you're still looking at that category.
I tried A Next.js mode that serves the unchanging parts of a page instantly and streams in the parts that change per request. too, and took it out again. With every page split by language, I couldn't get anything out of it.
Seeing your own edits
The part of the admin I've reworked most is the moment right after someone clicks Save.
The public pages don't go to the database on every visit. Next's data cache keeps a copy of each read, filed under a tag for its kind of content, products, categories or articles, and every save clears the matching tag, so the next visitor gets fresh data.
Moving the site to Next.js 16 changed the rules. The one-argument revalidateTag is deprecated, and the recommended form is revalidateTag(tag, "max"), which keeps serving the old data while it fetches the new.
That's Serve the cached copy you have right away, and fetch a fresh one in the background for the next request.. It works like a newsstand that keeps selling this morning's paper until the afternoon edition arrives. For a blog, that's a fine deal. For someone who just fixed a typo in a product name and hit refresh to check it, it's a confusing one.
So saving now calls updateTag instead:
export const createArticle = async (args: unknown) => {
const article = await articleSchema.parseAsync(args);
await db.insert(articlesTable).values(article);
updateTag("articles");
};
updateTag expires the tag straight away, and the next request waits for fresh data. The Next.js docs call this read-your-own-writes, which is what a Save button should do.
The admin is still the one I wrote for the first version, running on Next.js 16 now.