Ricaleen Metal

Jovanus Hartono2025ricaleenmetal.co.id

Screenshot of 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:

(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.

1 April 2025articlesidcreated_atone rowper languagearticle_translationsarticle_idlanguage (ID, EN)titlecontentFrom 2 April 2025articlesidtitle_idtitle_encontent_idcontent_enthumbnailproducts and categoriesfollow the same shape
The first schema kept each language in its own row. A day later both languages moved into columns on the row itself.

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.

revalidateTag(tag, "max")Staff savea productNext loadold productLater loadnew productrefreshes in the backgroundupdateTagStaff savea productNext loadnew product
With the recommended max profile, revalidateTag serves the old data while it refreshes. updateTag expires the cache at once, so staff see what they saved.

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.