Skip to content
03Web Development · Full-stack Development · 2024Featured

Advanced Functional Materials Lab Profile Website

Summary

The public research site of ITB's Advanced Functional Materials Laboratory, in English and Indonesian, with a custom CMS so the lab publishes its own publications, people and news. As sole developer I built it from the database design up, around one typed wrapper used by every server action. Rebuilding the image pipeline took the main page's load (LCP) from 3.5 s to 1.2 s.

Role
Sole Full-stack Developer
Wireframes and schema to deployment
Timeline
Sep 2024 – Jul 2025
Freelance, remote
Client
University research lab
Institut Teknologi Bandung (ITB)
Stack
Next.js · MongoDB
Prisma · Cloudinary · Vercel
Stack
  • Next.js
  • React
  • Prisma
  • TypeScript
  • Zod
  • MongoDB
  • Auth.js
  • Vercel
  • TailwindCSS
  • Zustand
www.afmlabitb.com
The lab's public home page
(01)

The problem

A university research lab needed a public home for its research, one its own staff could keep up to date without a developer.

The lab's work is spread across publications, research topics, equipment, researchers, grants and news, and the readers are both Indonesian and international, so the site has to work in English and Indonesian. Content is written by lab staff, not developers, and an Indonesian translation often comes later than the English one.

Publications are linked to their authors and research topics, and a researcher also chooses which papers to feature on their own page. The pages lean on large research photos, which made the first version slow to show its main image.

  • Publications, research topics, equipment, people, grants, news and a gallery
  • English and Indonesian, with the Indonesian allowed to come later
  • A CMS where lab staff publish without a developer
  • Publications linked to their authors and research topics
  • Fast pages, even with large research photos
  • Two admin levels: admins and super admins
(02)

How I solved it

I built it alone, from the wireframes and the database schema to deployment. Three decisions shaped it.

01One way to write a mutation

Every change in the CMS goes through a server action, and every server action is wrapped by the same small function. It validates the input against a Zod schema before anything else runs, so the handler only ever sees parsed, typed data, and it always returns one of three shapes: field errors, an error, or the data. A matching React hook gives every form the same loading state and error handling.

The field-error keys come from the input type, so renaming a form field is a compile error rather than a silent miss at runtime. All 57 server actions, across 12 areas of the CMS, use the wrapper, have a schema and check the session first, with no exceptions. With only one way to write a mutation, nobody has to remember the checks.

lib/create-safe-action.tsTypeScript
export type FieldErrors<T> = { [K in keyof T]?: string[] }; export const createSafeAction = <TInput, TOutput>(  schema: z.Schema<TInput>,  handler: (data: TInput) => Promise<ActionState<TInput, TOutput>>) => {  return async (data: TInput): Promise<ActionState<TInput, TOutput>> => {    const result = schema.safeParse(data);    if (!result.success) {      return { fieldErrors: result.error.flatten().fieldErrors as FieldErrors<TInput> };    }    return handler(result.data);  };};
Trade-off

A hand-written wrapper instead of a library: about 30 lines to own, without another dependency's opinions.

02Two languages in the schema, not in duplicate pages

Every piece of content, from a publication title to a news article, is stored as one bilingual field: an English text and an optional Indonesian one. Titles that are missing in Indonesian fall back to the English, and longer text shows a short note that the Indonesian version isn't available yet, with a pointer to the English. So the lab can publish now and translate later without a broken page. It's one decision, made once in the schema, and every model inherits it.

The public pages live under /en and /id, and the sitemap lists both versions of every page with language alternates, so search engines find each language.

Trade-off

An Indonesian visitor can still meet English titles, or a "not translated yet" note, until the translation is in.

03An image pipeline that cleans up after itself

Lighthouse showed what was slowing the site: oversized hero images, about 1.1 MB of transfer that could be avoided. I rebuilt the image pipeline: images are stored on Cloudinary and shown through Next.js's image component, which sends resized versions instead of the originals, with a blur placeholder while the full image loads.

Generating every placeholder during the build ran the build out of memory, so I moved it to a small API route that makes a placeholder when an image is uploaded. And when an editor replaces an image, the old file is deleted from Cloudinary, so storage doesn't fill up with images nothing uses.

Trade-off

Each upload makes one extra request to generate its placeholder.

(03)

Results

to show the main content (LCP), from 3.5 s
1.2 s
server actions validated and session-checked
57 / 57
languages from one content model
2

I ran the same Lighthouse audit before and after: emulated desktop, the same throttling, the same page.

Metric Before (Nov 2024) After (Aug 2026)
Largest Contentful Paint 3.5 s 1.2 s
First Contentful Paint 1.8 s 0.8 s
Speed Index 1.9 s 1.1 s
Total Blocking Time 0 ms 90 ms
Cumulative Layout Shift 0 0

The two runs are 21 months apart on different Lighthouse versions, and the gain comes from several changes, the image pipeline above all, not a single fix. Blocking time went up as the site gained features; 90 ms is still well inside Lighthouse's "good" range. The site is live at afmlabitb.com.

(04)

Screens

Researchers, grouped by their role in the lab
Publications, sorted and filtered by topic or author
Every field in English and Indonesian; Indonesian can come later.
The site on a phone.

Next project04 / 06

Katwara QA

NDA

Got something in mind?

Let's build the next one.