Silk
Silk is a digital art house that unites gallery representation with a creative and tech studio. Its content is managed by Strapi CMS, with a block-based editor and an image pipeline. Its online store runs in the same platform using Shopify Storefront API.
- Year
- 2025 - present
- Role
- Frontend Developer
- Stack
- Next.js, React, TypeScript, JavaScript, Strapi, Shopify, HTML / CSS, Bunny CDN, sharp, wagmi, viem, ethers, Chainlink VRF
Static content to a headless CMS
Level 1 · System context: Silk
Silk is a media- and magazine-heavy site, and its content keeps growing with new articles, artists, artworks and events. While that content lived in code, editors and the team couldn't publish with autonomy: every addition needed a developer and a release.
I moved the static content in the repository, such as the hero section, magazine articles, artists and collections, into the Silk CMS Strapi repository so that editors can publish these changes themselves. I integrated it with the UI and developed a revalidation mechanism that refreshes pages when content is published. I also created a shared fetch function that interacts with Strapi on both the server and the client.
Pages fetch their content from Strapi on the server and stay cached until that content changes. When an editor publishes, Strapi calls a secure webhook that clears only the pages showing the changed content, so the next visit shows the update.
Magazine articles can also be scheduled. An editor sets a go-live time while the article is a draft, and a GitHub Actions job publishes due articles twice a day, at 07:00 and 19:00 UTC, so an article goes live at the first run after its time.
Editors publish without a developer or a deploy, and changes go live on publish. Removing the static content and its media took about 440 MB of files out of the deployed site.
Three layers in silk-web: server-component pages, per-domain data utils (magazine, artists, artworks, collections, hero, events) on top of one fetchStrapi client, and the Next.js data cache. Every fetch is tagged by content type. The revalidate route checks a shared secret, maps Strapi's singular model names to those plural tags, and clears only the matching tags, including related ones. Server reads call Strapi directly; client-side reads go through a proxy route that allows only known models. Preview uses Strapi's preview button: the CMS opens a preview route with a shared secret, which enables Next.js draft mode. In draft mode and outside production, fetches request draft content. The revalidate route is the only thing that clears the cache.
Silk is a media- and magazine-heavy site, and its content keeps growing with new articles, artists, artworks and events. While that content lived in code, editors and the team couldn't publish with autonomy: every addition needed a developer and a release.
I moved the static content in the repository, such as the hero section, magazine articles, artists and collections, into the Silk CMS Strapi repository so that editors can publish these changes themselves. I integrated it with the UI and developed a revalidation mechanism that refreshes pages when content is published. I also created a shared fetch function that interacts with Strapi on both the server and the client.
Pages fetch their content from Strapi on the server and stay cached until that content changes. When an editor publishes, Strapi calls a secure webhook that clears only the pages showing the changed content, so the next visit shows the update.
Magazine articles can also be scheduled. An editor sets a go-live time while the article is a draft, and a GitHub Actions job publishes due articles twice a day, at 07:00 and 19:00 UTC, so an article goes live at the first run after its time.
Editors publish without a developer or a deploy, and changes go live on publish. Removing the static content and its media took about 440 MB of files out of the deployed site.
Three layers in silk-web: server-component pages, per-domain data utils (magazine, artists, artworks, collections, hero, events) on top of one fetchStrapi client, and the Next.js data cache. Every fetch is tagged by content type. The revalidate route checks a shared secret, maps Strapi's singular model names to those plural tags, and clears only the matching tags, including related ones. Server reads call Strapi directly; client-side reads go through a proxy route that allows only known models. Preview uses Strapi's preview button: the CMS opens a preview route with a shared secret, which enables Next.js draft mode. In draft mode and outside production, fetches request draft content. The revalidate route is the only thing that clears the cache.
Rich text to a block-based content system
Level 2 · Inside silk-cms: silk-cms
Editors get a visual editor per block instead of remembering Markdown syntax, and the site renders typed data instead of parsing Markdown. A safe, repeatable script mattered because it ran against production content.
Articles were one long Markdown field with custom tags for images, carousels and grids. I replaced it with Strapi dynamic zones built from blocks (rich text, image, video, embed, carousel, grid), first for magazine articles, then, after that migration succeeded, for artists, artworks, collections and podcast transcripts.
A migration script parses the old Markdown, including the custom carousel, grid and media syntax, into Strapi block JSON, resolves media to library IDs, and writes through Strapi's admin API. It runs as a dry run first, skips entries that already have blocks, leaves the source field untouched, and only re-publishes entries that were already published. On the site, one ContentRenderer maps each block type to a React component.
Five content types moved to blocks, and the legacy Markdown renderer was removed, about 1,500 lines. A custom ArticleBuilder gives editors the same block editor on every dynamic zone. Drag and drop lets editors reorder blocks, carousel slides and grid items, so they can move sections around within the content.
The block set lives in Strapi as shared components, so every content type uses one schema. On the site, shared populate maps request the same block fields everywhere, a ContentBlock union types the response, and ContentRenderer switches on the block type. Rich text renders through a custom component, with no Markdown library and no dangerouslySetInnerHTML. During the move the renderer supported both formats, so content types could migrate one at a time.
Editors get a visual editor per block instead of remembering Markdown syntax, and the site renders typed data instead of parsing Markdown. A safe, repeatable script mattered because it ran against production content.
Articles were one long Markdown field with custom tags for images, carousels and grids. I replaced it with Strapi dynamic zones built from blocks (rich text, image, video, embed, carousel, grid), first for magazine articles, then, after that migration succeeded, for artists, artworks, collections and podcast transcripts.
A migration script parses the old Markdown, including the custom carousel, grid and media syntax, into Strapi block JSON, resolves media to library IDs, and writes through Strapi's admin API. It runs as a dry run first, skips entries that already have blocks, leaves the source field untouched, and only re-publishes entries that were already published. On the site, one ContentRenderer maps each block type to a React component.
Five content types moved to blocks, and the legacy Markdown renderer was removed, about 1,500 lines. A custom ArticleBuilder gives editors the same block editor on every dynamic zone. Drag and drop lets editors reorder blocks, carousel slides and grid items, so they can move sections around within the content.
The block set lives in Strapi as shared components, so every content type uses one schema. On the site, shared populate maps request the same block fields everywhere, a ContentBlock union types the response, and ContentRenderer switches on the block type. Rich text renders through a custom component, with no Markdown library and no dangerouslySetInnerHTML. During the move the renderer supported both formats, so content types could migrate one at a time.
Image optimization
Level 2 · Inside silk-web: silk-web
Strapi Cloud served media with a one-hour cache, so the same files were pulled again and again: about 75 GB of transfer a month for 4 GB of storage, billed as overage. Bunny's edge caching and on-demand optimization remove that cost. Generating the blurhash in one hook, instead of several upload paths, means every uploaded image gets one.
Images load at the size each layout needs, with a blurred placeholder while they arrive. Media is stored on and served from Bunny CDN.
When a file is uploaded or replaced, a Strapi lifecycle hook reads the original, resizes it to 32×32 with sharp and stores a blurhash. silk-web decodes the blurhash into a tiny PNG on the server, so the placeholder is already in the HTML. StrapiImage sets sizes per layout, and Bunny serves optimized sizes from the edge.
856 media files moved to Bunny with checksum verification. Images arrive in optimized sizes.
The work is split by when it happens. On upload, Strapi stores the original on Bunny and computes the blurhash once. On request, the silk-web server fetches url, size, blurhash and formats, and decodes the placeholder. In the browser, the image is requested at the size the layout needs and cached at the edge. The move to Bunny ran as resumable, tested scripts: copy files with SHA-256 checks, then repoint database rows only after confirming each file is reachable.
Strapi Cloud served media with a one-hour cache, so the same files were pulled again and again: about 75 GB of transfer a month for 4 GB of storage, billed as overage. Bunny's edge caching and on-demand optimization remove that cost. Generating the blurhash in one hook, instead of several upload paths, means every uploaded image gets one.
Images load at the size each layout needs, with a blurred placeholder while they arrive. Media is stored on and served from Bunny CDN.
When a file is uploaded or replaced, a Strapi lifecycle hook reads the original, resizes it to 32×32 with sharp and stores a blurhash. silk-web decodes the blurhash into a tiny PNG on the server, so the placeholder is already in the HTML. StrapiImage sets sizes per layout, and Bunny serves optimized sizes from the edge.
856 media files moved to Bunny with checksum verification. Images arrive in optimized sizes.
The work is split by when it happens. On upload, Strapi stores the original on Bunny and computes the blurhash once. On request, the silk-web server fetches url, size, blurhash and formats, and decodes the placeholder. In the browser, the image is requested at the size the layout needs and cached at the edge. The move to Bunny ran as resumable, tested scripts: copy files with SHA-256 checks, then repoint database rows only after confirming each file is reachable.
Work Luck Dice: VRF rolls, burns and silver claims
Level 3 · Inside the dice route: Work Luck Dice
Chainlink VRF gives randomness that neither the team nor the collector can predict or influence, which the artwork's concept depends on. Burning on-chain and recording the transaction hash ties each physical die to a verified burn, so fulfilment doesn't rely on the collector's word.
The web experience for PLAY, a Jack Butcher artwork. Each roll is priced in USD, paid in ETH, and mints six dice faces. Collectors who burn 6,666 dice, the equivalent of 1,111 complete rolls, claim a physical silver die.
Rolls go through a Manifold ERC-1155 extension contract. rollDice takes the USD price plus the VRF cost, and Chainlink VRF v2.5 Direct Funding returns the randomness to rawFulfillRandomWords, which mints the six faces to the roller. The frontend waits for fulfilment, then polls the result. The claim view burns the set through the creator contract with balanced amounts per face, then records the transaction hash in Strapi, and the collector adds an email for fulfilment.
Collectors could roll, track their holdings and roll history, and claim a silver die from the same page.
Three layers: the DiceMintExtension contract, which holds pricing, pending rolls, VRF requests and minting; the silk-web roll and claim views, which read holdings and send transactions through the wallet client with ethers. The workLuckDice service reads the contract's event logs to rebuild each collector's roll history and to wait for a roll's faces; and a Strapi roll-dice-burn-results collection, which stores each burn's wallet, transaction hash, email and sent status.
Chainlink VRF gives randomness that neither the team nor the collector can predict or influence, which the artwork's concept depends on. Burning on-chain and recording the transaction hash ties each physical die to a verified burn, so fulfilment doesn't rely on the collector's word.
The web experience for PLAY, a Jack Butcher artwork. Each roll is priced in USD, paid in ETH, and mints six dice faces. Collectors who burn 6,666 dice, the equivalent of 1,111 complete rolls, claim a physical silver die.
Rolls go through a Manifold ERC-1155 extension contract. rollDice takes the USD price plus the VRF cost, and Chainlink VRF v2.5 Direct Funding returns the randomness to rawFulfillRandomWords, which mints the six faces to the roller. The frontend waits for fulfilment, then polls the result. The claim view burns the set through the creator contract with balanced amounts per face, then records the transaction hash in Strapi, and the collector adds an email for fulfilment.
Collectors could roll, track their holdings and roll history, and claim a silver die from the same page.
Three layers: the DiceMintExtension contract, which holds pricing, pending rolls, VRF requests and minting; the silk-web roll and claim views, which read holdings and send transactions through the wallet client with ethers. The workLuckDice service reads the contract's event logs to rebuild each collector's roll history and to wait for a roll's faces; and a Strapi roll-dice-burn-results collection, which stores each burn's wallet, transaction hash, email and sent status.
Silk Store
Level 3 · Inside the store route: Silk Store
The plan was building a store page within the platform, with Shopify as the backend through its Storefront API. Building the UI in the platform keeps the store in the site's design and navigation, while Shopify handles the catalogue, cart and checkout. Featured sections are set in Shopify metafields, so the team can change the homepage without a code change.
Silk Store sells Silk's products through Shopify, built into the Silk site. Visitors browse products, collections and categories, add items to a cart, and check out. Product pages also show the artist's information, taken from Strapi.
Products, collections and categories come from Shopify's Storefront API through one GraphQL client with generated TypeScript types. Featured sections and the homepage drop carousel are driven by Shopify metafields, with a layout and size set for each section. The cart is a Storefront API cart whose ID is kept in local storage. Adding, updating and removing items call the cart mutations, and checkout goes to the cart's checkout URL.
Each product has an artist name entered in a Shopify metafield. The product page uses that name to find the matching artist in Strapi and shows the artist's information below the product.
The store runs on silk-web with a homepage, category and collection pages, and product pages that show related items.
Three layers. Store pages under app/store are server components with revalidate = 60, so Shopify data refreshes at most once a minute without a rebuild. Data helpers in lib/integrations/shopify/client.ts wrap one fetchGraphQL call to the Storefront API and expose products, collections, categories, featured sections and drop carousel items, typed by codegen.
Cart work is separate. The GraphQL cart operations live in their own module, and CartProvider, mounted in the layout, keeps the cart ID in local storage and exposes add, update and remove to the cart modal. The cart's checkout URL hands visitors to Shopify's checkout, so payment stays with Shopify. Shopify data isn't tied to the Strapi revalidation route; it refreshes on the 60-second interval.
The plan was building a store page within the platform, with Shopify as the backend through its Storefront API. Building the UI in the platform keeps the store in the site's design and navigation, while Shopify handles the catalogue, cart and checkout. Featured sections are set in Shopify metafields, so the team can change the homepage without a code change.
Silk Store sells Silk's products through Shopify, built into the Silk site. Visitors browse products, collections and categories, add items to a cart, and check out. Product pages also show the artist's information, taken from Strapi.
Products, collections and categories come from Shopify's Storefront API through one GraphQL client with generated TypeScript types. Featured sections and the homepage drop carousel are driven by Shopify metafields, with a layout and size set for each section. The cart is a Storefront API cart whose ID is kept in local storage. Adding, updating and removing items call the cart mutations, and checkout goes to the cart's checkout URL.
Each product has an artist name entered in a Shopify metafield. The product page uses that name to find the matching artist in Strapi and shows the artist's information below the product.
The store runs on silk-web with a homepage, category and collection pages, and product pages that show related items.
Three layers. Store pages under app/store are server components with revalidate = 60, so Shopify data refreshes at most once a minute without a rebuild. Data helpers in lib/integrations/shopify/client.ts wrap one fetchGraphQL call to the Storefront API and expose products, collections, categories, featured sections and drop carousel items, typed by codegen.
Cart work is separate. The GraphQL cart operations live in their own module, and CartProvider, mounted in the layout, keeps the cart ID in local storage and exposes add, update and remove to the cart modal. The cart's checkout URL hands visitors to Shopify's checkout, so payment stays with Shopify. Shopify data isn't tied to the Strapi revalidation route; it refreshes on the 60-second interval.