Analis Architects

Jovanus Hartono2025analisarchitects.com

Screenshot of Analis Architects
On this page

Analis Architects is an architecture and interiors studio, and this is its portfolio site. The home page opens on a full-screen carousel of the studio's projects, with a strip of project videos further down. The works page puts every project in a grid you can filter. Each project gets a page of its own, with a row of photos that scrolls by itself, a list of the project's details and a write-up, and a gallery page puts every photo in one place.

The studio adds and edits all of it themselves, in a Payload CMS admin that runs inside the same Next.js app as the site.

I'd tried Payload once before, on Ricaleen Metal, and backed off, because I'd have had to learn its docs and syntax before building anything. This time I had the time to learn it, and Payload 3 was built around Next.js.

Payload inside Next

The public site and the admin are two route groups in one app, (frontend) and (payload). A folder name in brackets groups pages without adding to the URL, so each half gets its own root layout. Content lives in Postgres through Payload's Vercel Postgres adapter, and photos and videos go to Vercel Blob.

My favourite part is how pages read content. They call Payload's local API, which is a plain function call in the same process:

const {
  docs: [project],
} = await payload.find({
  collection: "architectures",
  where: { slug: { equals: slug } },
  limit: 1,
});

There's no HTTP request, no API key and no webhook in between.

An admin made of config

Payload builds the admin from the same config that defines the data. A project is a Payload's word for a kind of document, like a database table with its own admin screen., a list of fields, and each field's type decides what the studio gets in the form:

Field typeIn the form
relationshipA picker, with a button to add a new project type on the spot
radioA row of statuses, from Conceptual Design to Completed
upload with hasManyA list of photos you can drag into order

Layout is config too: a row puts Project Type, Year and Award side by side, and position: "sidebar" moves the slug and the video to the right. Here's a slice of it:

{
  type: "row",
  fields: [
    { name: "projectType", type: "relationship", relationTo: "project-types", required: true },
    { name: "year", type: "number", required: true },
    { name: "award", type: "text" },
  ],
},
{
  name: "status",
  type: "radio",
  defaultValue: "conceptual",
  options: [
    { label: "Conceptual Design", value: "conceptual" },
    { label: "In-Preparation", value: "in_preparation" },
    { label: "Under Construction", value: "under_construction" },
    { label: "Completed", value: "completed" },
  ],
},

And this is the form it turns into:

A project called The Tambi Dialogue open in the Payload admin: fields for the title, project type, year, award, site, site area, building area and tagline, a row of status options with Conceptual Design selected, and the start of the rich text description. A sidebar on the right holds the slug and an empty video upload.
A project in the admin, running on my machine with one of the studio's published projects. Every field on the form comes from the collection config.

The slug fills itself in. A small hook on the field turns the title into a slug when it's left empty, so "The Tambi Dialogue" becomes the-tambi-dialogue:

hooks: {
  beforeChange: [
    ({ value, data }) => (!value && data?.name ? formatSlug(data.name) : value),
  ],
},

The write-up on each project page is Payload's rich text, edited in Lexical, the text editor Payload builds on. Out of the box it has what you'd expect from a text editor, like headings, lists, links and quotes. Payload also lets you add your own A reusable group of fields that an editor can drop into the text, like a gallery or a quote card. to it, with its BlocksFeature, and I added one: a gallery, a row of photos or drawings sitting between two paragraphs.

It's still the part of this project that amazes me most, because of how little of it I had to write. Here's the whole block:

export const GalleryBlock: Block = {
  slug: "gallery",
  interfaceName: "GalleryBlock",
  fields: [
    {
      name: "gallery",
      type: "upload",
      hasMany: true,
      required: true,
      relationTo: "media",
    },
  ],
};

And here's the editor being told about it:

editor: lexicalEditor({
  features: ({ defaultFeatures }) => [
    ...defaultFeatures,
    BlocksFeature({ blocks: [GalleryBlock] }),
  ],
}),

That was everything the admin needed. Type a slash in a write-up, and Gallery is at the top of the menu, under Blocks. Pick it, and a card appears in the text with the same photo picker as any other upload field, so the studio can upload new photos, choose ones already in the library and drag them into order. Four of the studio's architecture write-ups have one now.

Adding a gallery block in the admin, running locally: a slash opens the menu, Gallery drops an empty block into the text, and three photos come from the media library.
One block configslug, interfaceName, one upload fieldPayload generatesSlash menuentryEditor cardupload pickerBlock nodein Lexical JSONGalleryBlockTS interfacewhat the studio seeswhat my code getsblocks.gallery → <GalleryBlock />the RichText converter on the siteA carousel that opens the lightbox
One block config gives the studio a new thing to put in a write-up, and gives my code a typed node to render.

Payload saves the block inside the write-up's JSON, as a node that holds the chosen photos. And because the block has an interfaceName, Payload generates a GalleryBlock type for it in payload-types.ts.

On the site, the write-up used to be turned into an HTML string with convertLexicalToHTML and injected into the page. An HTML string can't hold a carousel you drag, so I switched to Payload's React renderer, RichText, which takes a converter for each kind of node. The default converters handle paragraphs, headings and the rest, and I added one for gallery blocks:

type NodeTypes = DefaultNodeTypes | SerializedBlockNode<GalleryBlockType>;

export const jsxConverters: JSXConvertersFunction<NodeTypes> = ({
  defaultConverters,
}) => ({
  ...defaultConverters,
  blocks: {
    gallery: ({ node }) => <GalleryBlock {...node.fields} />,
  },
});

The generated type goes straight into NodeTypes, so node.fields arrives already typed as the block the studio filled in.

GalleryBlock itself is a small client component: an Embla carousel you can drag freely, with a square crop of each photo. Tapping a photo opens it in the lightbox, the same <dialog> that the row of photos at the top of the page uses. That dialog used to live inside the photo row, so to share it I moved it into a A React wrapper that hands the same thing to every component inside it, without passing it down by hand. around the whole site. Now anything can open a photo in it.

The Tambi Dialogue on the live site. The photo row at the top scrolls by itself, and further down the write-up holds a gallery block of drawings, which you can drag and tap to open in the lightbox.

Photos

The rest of the admin work is mostly about photos. The admin uploads files from the browser straight to Blob, so a big photo never has to squeeze through the 4.5 MB request limit on a Vercel function. Payload stores each file's public Blob URL, so images load from Blob directly instead of streaming through Payload. next/image then serves AVIF or WebP, caches the result for about a year, and allows widths up to 2560px and quality 100 for the full-width photos on the home page.

Cards that know where they were

The works page shows every project in a A grid of columns where each card keeps its own height, so the cards stack like bricks instead of lining up in rows., three columns wide on a big screen, built with CSS columns. Above it, a Filter button opens two rows of options: scope, for interior or architecture, and project type, for commercial or residential.

Every part of that page moves a little, and all of it is GSAP. Each animation runs inside useGSAP, GSAP's hook for React. It scopes selectors like .work-item to the component's own container, and it reverts every GSAP's word for one animation: some properties moving from one value to another over time. when the component unmounts, so nothing keeps running after you leave the page.

When the page loads, every card starts invisible, and GSAP fades them in one after another, 0.08 seconds apart. Opening the filter is a small sequence of its own. The panel grows from nothing to its natural height, the two row labels fade in, and then the options fade in one at a time:

gsap.to(filterRef.current, {
  autoAlpha: 1,
  height: "auto",
  duration: 0.5,
  ease: "power4.out",
});

gsap
  .timeline()
  .to(filterQuery("p"), { autoAlpha: 1, duration: 0.3 })
  .to(filterQuery("button"), { autoAlpha: 1, duration: 0.3, stagger: 0.07 });

autoAlpha is GSAP's shortcut for opacity plus visibility: at zero the element is also hidden, so an invisible option can't be clicked. A timeline plays its steps one after another, and stagger: 0.07 starts each button 0.07 seconds after the one before it.

Closing it shrinks the panel back to zero. Before either one starts, any tween still running on the panel is killed, so opening and closing it quickly doesn't pile animations on top of each other.

The filter itself was the fun part. Changing it reshuffles the whole grid: some cards disappear, and the rest land in different columns and rows. CSS can't animate that, because a card's position isn't a property anyone sets. It's wherever the columns happen to put it.

GSAP's Flip plugin gets around that with a trick named after its steps (Paul Lewis explains the technique well):

  1. First. Record where everything is.
  2. Last. Make the change, and see where everything lands.
  3. Invert. Move each element back to where it was with a transform, so nothing seems to have moved yet.
  4. Play. Animate those transforms back to zero.
function handleFilterChange(key: FilterKey, value: string) {
  const state = Flip.getState(q(".work-item"));
  setActiveFilters((af) => ({ ...af, [key]: value }));

  requestAnimationFrame(() => {
    Flip.from(state, {
      duration: 0.5,
      stagger: 0.08,
      absolute: true,
      scale: true,
      onEnter: (elements) =>
        gsap.fromTo(
          elements,
          { scale: 0.9, autoAlpha: 0 },
          { autoAlpha: 1, scale: 1, delay: 0.2, duration: 0.3 }
        ),
      onLeave: (elements) =>
        gsap.to(elements, { autoAlpha: 0, scale: 0.9, duration: 0.3 }),
    });
  });
}

Flip.getState runs before the state update, while every card is still in its old place. The update hides the cards that don't match with display: none. requestAnimationFrame waits for the next frame, by which point React has put the new layout on the page, and Flip.from does the invert and the play.

Flip.getState(cards)every card's box, before anything movesset the filterReact re-rendersnon-matching cards hide, the rest reflownext frameFlip.from(state)each card starts in its old boxStay: slide over0.08 s apartEnter: fade infrom 90%, after 0.2 sLeave: fade outdown to 90%
Flip records where every card is, lets React move them, then animates each one from its old box to its new one.

absolute: true takes the cards out of the columns while they move, so the columns don't reflow underneath them halfway through. stagger sends them off 0.08 seconds apart, which is what gives the grid its ripple. Cards that come back grow in from 90% after a short delay, and the ones on their way out shrink and fade.

The works page on the live site. The cards fade in on load, the filter panel opens, and switching between Interior, Architecture and All moves every card to its new place.

A strip that scrolls sideways

The home page has a strip of the projects that come with a video, each one a video beside its details. On a wide screen, when the strip reaches the top, it pins under the header, and scrolling down slides it to the left until the last project is in view. Then the page carries on. That's ScrollTrigger, which ties an animation to the scroll position:

gsap.to(figures, {
  xPercent: -80 * (figures.length - 1),
  ease: "none",
  scrollTrigger: {
    trigger: sectionRef.current,
    start: "top 72px",
    end: `+=${sectionRef.current.offsetWidth}`,
    pin: true,
    scrub: 1,
  },
});

Each project takes up 80% of the strip's width, so moving every one left by 80% for each project after the first brings the last one into view. start: "top 72px" pins the strip right under the 72-pixel header, and the pin lasts for one strip's width of scrolling. scrub: 1 ties the movement to the scrollbar with a second of catch-up, so a flick of the mouse wheel glides instead of jumping.

The home page on the live site, on a desktop. The strip pins under the header and slides sideways as the page scrolls.

On phones, the strip is a plain row you swipe through. The ScrollTrigger only exists inside gsap.matchMedia(), which runs code only while a media query matches, here screens 1024 pixels and wider. Below that the strip scrolls natively. ScrollSmoother, which smooths scrolling across the whole site, works the same way. It's created in a provider that wraps every page, and made again on every route change. On a phone, it's never created at all, and scrolling is the phone's own.

Half a save

Once the studio could edit everything, their changes had to reach the site without waiting for me to rebuild and publish it. So an afterOperation hook on both project collections calls revalidatePath after a save. revalidatePath throws away Next's cached copy of a page, so the next visit renders it fresh, and the hook calls it for the works list, the gallery and the project's own page.

Editor saves a projectin the Payload adminupdateByIDafterOperationcreate, updateByID, deleteByIDfirst version checkedupdate, deletebulk operations only, soa single save never matchedrevalidatePath/works/galleryproject pageNext visit renders freshPostgres through Payload's local API
Payload runs in the same process as Next, so its hook can clear Next's cache directly.

Here's the check it started with, and the one it ended up with:

// first version
if (["create", "update", "delete"].includes(operation)) {

// the fix
if (["create", "updateByID", "deleteByID"].includes(operation)) {

Can you spot it?

In Payload, update and delete are the bulk operations, the ones that change every document matching a query. Clicking Save on a single project is updateByID. So for nine days, new projects cleared the cache just fine, because create was right, while every edit and every delete went straight past the hook. The studio could fix a project in the admin and still see the old version on the site.

The fix was two words, in two files.

On Ricaleen, what put me off Payload was having to learn its docs and syntax before I could build anything. On Analis, the bug in the save hook came down to exactly that: the name Payload gives a single save.