How I built a CMS an arts institute can actually use

TipTap is not the story. Armenian owns the root URL, one form has to ship both languages, and the people who press Publish are not engineers.

YSTFI English homepage with a diamond-framed stage photograph and ENG language toggle
The public site in English. Same page in Armenian lives at the root URL, not under /hy.

I built the official website for the Yerevan State Institute of Theatre and Cinematography — YSTFI / ԵԹԿՊԻ. Next.js 15, Postgres, S3, Vercel. The repo is private. The product is not: students, applicants, and staff use it every week.

What I want to show here is not “I installed a rich text editor.” Hardcoded pages do not survive a semester. The rectorate should not open a pull request to change an admissions PDF. So I built an admin that matches the building: Armenian first, English second, and roles for people who actually work there.

YSTFI Armenian homepage with ARM language toggle and Armenian navigation
Same homepage, Armenian. ARM in the header is not a theme. It is the default locale.

The constraint: Armenian owns /

Most next-intl tutorials assume English is the default and every locale gets a prefix. That is wrong for this institute. The audience is in Yerevan. English is the second language, not the home language.

export const routing = defineRouting({
  locales: ['en', 'hy'],
  defaultLocale: 'hy',
  localePrefix: 'as-needed',
});

as-needed means /en/news/... for English and /news/... for Armenian. No /hy tax on the majority language. Middleware also turns locale detection off, so a browser set to English does not kidnap the Armenian homepage. Admin and the lobby TV kiosk opt out of locale routing entirely — they are tools, not public pages.

That decision leaks into the CMS. If the public site is bilingual, the editor cannot be a single English textarea with a “translate later” checkbox. Later never comes.


One form, two languages

Every managed field that appears on the public site has an English side and an Armenian side. Not two sites. Not a CSV import. One form, a tab, and a status so you can see which language is still empty.

Article translation tabs: Armenian needs content, English is ready and selected
Per-locale completeness on the article form. You can draft English first. You cannot pretend Armenian does not exist.

On articles the tabs live on the whole document: title, slug preview, and block content all swap with the locale. On documents, admissions copy, and media links I reused a smaller widget — English / Հայերեն on the same field.

Create Document Category dialog with English and Armenian title tabs filled with dummy studio notes
Dummy data only. The dialog is the same pattern: one slug, two titles, pick which public section it belongs to.
const [activeTab, setActiveTab] = useState<'en' | 'hy'>('en');

<button type="button" onClick={() => setActiveTab('en')}>
  English
</button>
<button type="button" onClick={() => setActiveTab('hy')}>
  Հայերեն
</button>

The boring part matters: required bilingual copy is a product rule, not a database nullability trick. Staff can publish one language before the other on some surfaces, but the UI never hides the empty side.

Titles also have to become URLs. Armenian in a path breaks S3 keys and a lot of share previews, so I transliterate into ASCII before slugifying.

function transliterateArmenian(input: string): string {
  let output = '';
  for (const char of input) {
    output += ARMENIAN_TO_LATIN_MAP[char] ?? char;
  }
  return output;
}

This is honest, not cute: ու is two characters (ո + ւ), so “ստուդիա” becomes stovdia, not studio. I found that while screenshotting a dummy article. The slug is still shareable. Perfect English phonetics was never the requirement — ASCII that does not explode in a bucket path was.

Create article form in Armenian with dummy title and auto-generated ASCII slug
Dummy Armenian title. The preview path is ASCII. Draft stays draft — this never hit production.

Staff think in blocks, not HTML

TipTap is how text gets bold. The model staff actually use is blocks: add a text block, add an image block, move them. News is not one giant HTML string in their head. It is a photo, then a paragraph, then another photo.

const addTextBlock = () => {
  updateBlocks([
    ...value.blocks,
    { id: uuid(), type: 'text', order: value.blocks.length, content: { text: '' } },
  ]);
};

const addImageBlock = () => {
  updateBlocks([
    ...value.blocks,
    {
      id: uuid(),
      type: 'image',
      order: value.blocks.length,
      content: { caption: '', alt_text: '', media_id: '' },
    },
  ]);
};

Each locale stores its own block list. Switching Armenian / English does not share a body and hope. The editor itself is TipTap with a small surface: starter kit, links, images, placeholder, and an HTML toggle for the one person who will paste a table.

useEditor({
  extensions: [
    StarterKit,
    Link.configure({ openOnClick: false }),
    Image,
    Placeholder.configure({ placeholder }),
  ],
  content: value,
  onUpdate: ({ editor }) => onChange(editor.getHTML()),
});

Images do not sit as anonymous blobs. Uploads go through an auth-wrapped API and land in S3 under the article type and slug, so deleting an article can clean the folder instead of leaving orphaned files in a junk bucket.

Create article form in English with dummy title Studio lighting workshop, draft status selected
English side of the same dummy article. Status is Draft. Featured image is shared across locales so you do not upload twice.

Roles that match the building

A SaaS CMS gives you “Admin” and “Member.” An institute has a PR person who should ship news, someone in the office who updates staff photos, and a viewer who only needs to check that the page is right. Super-admin is me, and it is not assignable from the users screen.

Admin dashboard with task cards for news, staff, documents, and admissions
The dashboard is task-first. “Manage Users” only shows for super-admin. Editors never see that button.
Role Who What they can do
super_admin Platform Everything, including users
admin Office / IT Content, staff, media, exam results, lobby display
editor PR / communications Create and edit articles, upload media
viewer Read-only Look. No publish.
export enum Role {
  SUPER_ADMIN = 'super_admin',
  ADMIN = 'admin',
  EDITOR = 'editor',
  VIEWER = 'viewer',
}

Pages go through NextAuth. APIs go through a withAdminAuth wrapper. Middleware maps routes to permissions. One detail I only learned after shipping: JWT permissions are frozen at login. When I added the entrance-hall display CMS, existing sessions did not have the new permission bit. Forcing everyone to log out was not an option during a workday, so that route grants by role instead of the stale permission array.

// JWT permissions are frozen at login; grant by role so existing
// admin sessions can open the new display CMS without re-auth.
if (pathname.startsWith('/admin/display')) {
  return userRole === Role.ADMIN || userRole === Role.SUPER_ADMIN;
}

That is not a textbook RBAC diagram. That is what happens when a product keeps growing after people already have cookies.


Publish is a cache event — until it costs money

The original idea was clean. Public pages should not hit Postgres on every visit. When an editor saves a published article, invalidate the tags the homepage actually uses.

if (status === 'published' || article.status === 'published') {
  revalidateTag('homepage-content');
  revalidateTag(`articles-${articleType}`);
  revalidateTag(`article-${hashSlugForCache(article.slug)}`);
}

I even hashed slugs before using them as cache tags, because tag charset and length limits are real.

Then staff pages entered the picture. Editing one employee wanted to revalidate every faculty page that mentioned them. Hundreds of tags. Vercel Fast Origin Transfer went up about 6×. In February 2026 I archived the broad unstable_cache / ISR layer to stop the bleed. Article saves still call revalidateTag. The “cache everything, invalidate forever” version is not how the site runs today.

That is the honest version. Publish was supposed to be the only expensive moment. Over-invalidation made the expensive moment happen in the wrong place. I will write that war story separately. It is a different craft problem.


What this actually shows

Anyone can drop TipTap into a form. The work was deciding that:

  • Armenian is the default URL, not a translation plugin.
  • The editor UI must make the empty language visible.
  • Content is blocks because that is how the staff already think.
  • Roles follow the org chart, and later features have to respect sessions that already exist.
  • Caching is a cost center, not a badge.

The public site is live at ystfi.am. Screenshots here use dummy copy in a local admin session. No student records, no exam results, no pairing tokens.

Until next time: the lobby TVs. Cheap Android screens that ignore cursor: none, eat the Back key, and still have to show a 9:16 playlist without a person standing in the hall.