Gülçin Uras
( Loading )
01

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
Highlights

Static content to a headless CMS

Level 1 · System context
browsesloads imagesREST + publish webhookeditsstores mediasyncs artworksVisitorpersonReads articles,browses artworkssilk-webNext.js on VercelPages, data layer,image renderingsilk-cmsStrapi 5 on Strapi CloudContent types,blocks, media libraryEditorpersonWrites andpublishes contentBunny CDNstorage + optimizerMedia files,optimized sizesEthereum / Manifoldchain + marketplaceNFT metadata,listings, auctions
PersonSilkStrapiExternalTap a box marked + to zoom in · scroll sideways

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.

Rich text to a block-based content system

Level 2 · Inside silk-cms
silk-cms · Strapi 5fills zoneson publishoriginalsafterCreatereads originalArticleBuilderadmin extensionEditor for everydynamic zoneContent types15 APIsDynamic zones onarticles, artists, …article.* blockscomponentsRich text, image, video,embed, carousel, gridMigration scriptsone-off, now removedMarkdown → blocks,idempotent, dry runUpload + BunnyproviderOriginals storedon BunnyPublish webhookStrapi adminCalls silk-web'srevalidate routeFile lifecycleblurhashUtilsBlurhash oncreate + replacesilk-webNext.jsClears taggedcache on publishBunny CDNstorage + optimizerMedia files
StrapiSilkExternalTap a box marked + to zoom in · scroll sideways

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.

Image optimization

Level 2 · Inside silk-web
silk-web · Next.js App Routerartwork detailsized URLsloads contenttagged fetchREST + tokenwebhookContentRenderercomponentMaps Strapi blocksto React componentsPagesserver componentsMagazine, artists,artworks, eventsStrapiImagecomponentBlur placeholder,sizes per layoutfetchStrapidata layerPer-domain utils,tagged fetchesNext data cachetags, no time expiryCleared per modelon publishRevalidate route/api/strapi/revalidateSecret check, clearsmatching tagsBunny CDNstorage + optimizerServes optimizedimage sizessilk-cmsStrapi 5REST API +publish webhookSilk Store/storeProducts, cart andcheckout on ShopifyWork Luck Dice/artworks/play/roll-diceRoll, burn and claimthe LUCK dice
Next.js UINext.js data layerExternalStrapiTap a box marked + to zoom in · scroll sideways

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.

Work Luck Dice: VRF rolls, burns and silver claims

Level 3 · Inside the dice route
silk-web · /artworks/play/roll-dicerenderscostcost, pollingsignssigns burnrecords claimestimatesrandomness via callbackmints facesPLAY pageserver pageLoads artwork from Strapi,renders the flowRollDiceFlowcomponentTabs: rolling, orders,learn moreworkLuckDiceserviceRoll cost, balances,event reads, pollingClaim viewLuckRollDiceClaimViewBurn 6,666 dice,email for fulfilmenthooks.tsreact-query hooksOwned dice, roll cost,burn mutationRoll viewLuckRollDiceViewRoll count, price,reveal animationBurn resultsStrapi collectionWallet, burn tx hash,email, claimed flagWalletwagmi, RainbowKitSigns roll andburn transactionsChainlink VRFv2.5 direct fundingRandomness, callsback with the seedDiceMintExtensionManifold extensionPricing, pending rolls,face mints on callbackCreator contractManifold ERC-1155Holds dice tokens,burns the set
Next.js UIServiceHooksStrapiWallet, chain, VRFTap a box marked + to zoom in · scroll sideways

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.

Silk Store

Level 3 · Inside the store route
silk-web · /storeartist infolookup by nameproductscatalogueusescheckoutUrlcallscart mutationsStore pagesserver componentsListing, collection, categoryand product routes, revalidate 60Artist infoArtistSummaryShown at the bottomof the product pageCart modalcomponentLines, quantities,checkout buttonShopify data helperslib/integrations/shopifyProducts, collections andthe artist metafieldCartProviderReact contextCart ID in local storage,add, update, removeCart GraphQLstorefront/cart.tsCreate, add, updateand remove mutationsShopify checkoutexternalPayment and orderhandlingStrapi artistssilk-cms, getArtistByNameBio and works, matchedby the Shopify artist nameShopify Storefront APIexternalCatalogue, collections,cart
Next.js UINext.js data layerHooksExternalStrapiClick the canvas, the path above, or Esc to zoom out

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.

Next project — 02Tinyman