~ 14 min read
Self Hosting Generative Open Graph Art on the Edge with Supabase
Having great Open Graph images with which to share your articles on social media allows them to stand out from the crowd and hopefully get more eyeballs reading them. For the last couple of years I’ve been using a fairly simple SVG background, but I’ve wanted something that could take them a step further. Back in October 2022, I saw this great write up of how Matthew Ström is using flow fields to produce art for his own site and it’s stuck in my mind since. It uses the concept of flow fields to draw beautiful, randomised art for every article on his site. In this post I’ll show you how I’ve used this to generate my own version you can see below which I self-hosted with Supabase Edge Functions on fly.io. Feel free to jump around using the links below.
- Flow Fields
- Drawing Field Paths with SVG
- Generating OpenGraph Images
- Self Hosting as a Supabase Edge Function
- Final Thoughts
This post was written as part of Supabase’s launch week - go check it out for all the other exciting work people have done.
Flow Fields
A Flow field is a concept used in subjects such as fluid dynamics and crowd simulation which typically guides the movement of something through a space. It can be represented as a grid or array where each element contains a vector representing direction and magnitude (or flow). Crucially for this work, it can be used to determine how to draw a number of pretty paths based on a random number.
Matthew has shared his work on Open Graph images in the following repo, it uses a canvas on which different size “flow lines” are drawn with JavaScript. He spent a heap of time researching the maths behind figuring out how to place these paths correctly so they are aesthetically pleasing - so I’d point you to that if you’re interested in digging deeper. It’s originally based on the work of Tyler Hobbs who has written a bunch of interesting articles on how to produce generative art. Be sure to check them out if you’re interested in how you can get started creating your own or any of the maths involved behind what I talk about here.
Once the flow lines are rendered, they’re added to a template which includes a title and subheading used as the basis for the open graph image for an article. In Matthews work, he takes a screenshot of the browsers viewport using puppeteer and returns the image - the repo parcels this all up as a Serverless Vercel function which can be taken and deployed there.
In looking at this I noted a number of things:
- Rendering flat 2D lines and text is also possible with SVG
- Using SVG drops the dependency on Puppeteer, since we no longer need to take a screenshot
- It should be simple to make such a function work with Supabase Edge functions
- Supabase Edge Functions can now be self hosted!
I therefore decided it might be fun to convert Matthews work so that it used SVG instead and try hosting it myself.
Drawing Field Paths with SVG
I started out by trying to get things working within next.js. This would be simpler to start out there and get an API tested prior to working in any particular service providers complexity.
I converted the main file src/index.js to TypeScript and made sure that the functions responsible for the bulk of the work now all worked with a new FlowLine type. I was careful not to change too much since it involves some recursive functions I didn’t fancy debugging. Each FlowLine has a path, colour and width which was calculated randomly from a seed value based on the title of the article. I hardcoded all these since I just wanted to see if I could render an image that resembled what I was expecting.
type FlowLine = {
path: string;
color: string;
width: number;
}
The SVG paths are drawn from two commands “move to” and “line to”. In our function we take the first point from a Flow Line and move to it’s x and y coordinates, before drawing lines to each one of the following points x and y coordinates. These commands a represented very simply by a long string of M x y (move to) and L x y (line to) commands. Read more about SVG path command types on the Mozilla site.
The function therefore for drawing a SVG flow line ends up as follows:
let flowline: FlowLine = {
width: lineSize,
color: colorFromVector,
path: `M${Math.round(line[0][0])} ${Math.round(line[0][1])}`
}
for (let i = 1; i < line.length; i++) {
flowline.path += ` L${Math.round(line[i][0])} ${Math.round(line[i][1])}`
}
To render the entire background, we simply need to first ensure an appropriate background colour is used (also selected from the seed value) and then iterate every FlowLine created and draw all the SVG paths created.
export default function Page() {
return (
<svg width="1200" height="675" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill={BACKGROUND} />
{lines.map((line, i) =>
<path key={i} d={line.path} stroke={line.color} strokeWidth={line.width} fill="none" />
)}
</svg>
)
}
To my surprise, this actually worked! It took me aback a bit at how quickly I’d managed it. I was now able to generate a whole bunch of interesting backgrounds by messing with my hardcoded seed value.
Generating OpenGraph Images
I also needed to overlay the text for the title, subtitle and author. Frustratingly this wasn’t possible with pure SVG solution since SVG text doesn’t support background colours. To do so, I would need to layer a rectangle of with the background colour the same length as the text, which I don’t know until after it’s been rendered.
Instead, I opted to use @vercel/og - a handy library that will take HTML, CSS (and crucially SVG) and generate OpenGraph images from them.
I had to make a few changes here and decided to remove things like custom fonts that were in the original function. The text also renders the background differently when spanning over multiple lines. My actual ImageResponse involves some quite lengthy HTML but here’s an example of using @vercel/og to render a single SVG Flow line with some text styled with CSS attributes.
import { ImageResponse } from '@vercel/og';
export const config = {
runtime: "edge",
}
export default async function handler() {
return new ImageResponse(
(
<div
style={{
display: 'flex',
fontSize: 40,
width: '100%',
height: '100%',
background: '#233147',
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
}}
>
<div style={{
color: '#fff',
position: 'absolute',
backgroundColor: '#000',
padding: '10px',
}}>Here is some Text</div>
<svg width="1200" height="675" xmlns="http://www.w3.org/2000/svg">
<path d="M938 38 L939 41 L940 44 L941 47 L942 49 L943 52 L944 55 L945 58 L946 61 L947 63 L948 66 L949 69 L950 72 L951 75 L952 78 L953 81 L953 83 L954 86 L955 89 L956 92 L957 95 L957 98 L958 101 L959 104 L960 107 L960 110 L961 113 L962 115 L962 118 L963 121 L963 124 L964 127 L964 130 L965 133 L965 136 L966 139 L966 142 L967 145 L967 148 L968 151 L968 154 L968 157 L969 160 L969 163 L969 166 L970 169 L970 172 L970 175 L970 178 L970 181 L971 184 L971 187 L971 190 L971 193 L971 196 L971 199 L971 202 L971 205 L971 208 L971 211 L971 214 L971 217 L971 220 L971 223 L971 226 L971 229 L971 232 L970 235 L970 238 L970 241 L970 244 L969 247 L969 250 L969 253 L968 256 L968 259 L968 262 L967 265 L967 268 L966 271 L966 273 L966 276 L965 279 L964 282 L964 285 L963 288 L963 291 L962 294 L962 297 L961 300 L960 303 L959 306 L959 309 L958 312 L957 315 L956 317 L956 320 L955 323 L954 326 L953 329 L952 332 L951 335 L950 338 L949 340 L948 343 L947 346 L946 349 L945 352 L944 354 L943 357 L942 360 L941 363 L940 366 L939 368 L937 371 L936 374 L935 377 L934 379 L932 382 L931 385 L930 387 L928 390 L927 393 L926 395 L924 398 L923 401 L921 403 L920 406 L918 409 L917 411 L915 414 L914 416 L912 419 L911 421 L909 424 L908 427 L906 429 L904 432 L903 434 L901 436 L899 439 L897 441 L896 444 L894 446 L892 449 L890 451 L888 453 L887 456 L885 458 L883 460 L881 463 L879 465 L877 467 L875 470 L873 472 L871 474 L869 476 L867 478 L865 481 L863 483 L861 485 L859 487 L856 489 L854 491 L852 493 L850 495 L848 497 L846 499 L843 501 L841 503 L839 505 L836 507 L834 509 L832 511 L830 513 L827 515 L825 517 L822 519 L820 520 L818 522 L815 524 L813 526 L810 528 L808 529 L805 531 L803 533 L800 534 L798 536 L795 538 L793 539 L790 541 L788 542 L785 544 L783 545 L780 547 L777 548 L775 550 L772 551 L769 552 L767 554 L764 555 L761 556 L759 558 L756 559 L753 560 L750 562 L748 563 L745 564 L742 565 L739 566 L737 567 L734 569 L731 570 L728 571 L725 572 L723 573 L720 574 L717 575 L714 576 L711 576 L708 577 L706 578 L703 579 L700 580 L697 581 L694 582 L691 582 L688 583 L685 584 L682 584 L679 585 L676 586 L674 586 L671 587 L668 587 L665 588 L662 589 L659 589 L656 590 L653 590 L650 591 L647 591 L644 591 L641 592 L638 592 L635 592 L632 593 L629 593 L626 593 L623 594 L620 594 L617 594 L614 594 L611 594 L608 594 L605 594 L602 595 L599 595 L596 595 L593 595 L590 595 L587 595 L584 595 L581 595 L578 594 L575 594 L572 594 L569 594 L566 594 L563 594 L560 593 L557 593 L554 593 L551 593 L548 592 L545 592 L542 592 L539 591 L536 591 L533 591 L530 590 L527 590 L524 589 L521 589 L518 588 L516 588 L513 587 L510 587 L507 586 L504 585 L501 585 L498 584 L495 583 L492 583 L489 582 L486 581 L483 581 L480 580 L477 579 L475 578 L472 578 L469 577 L466 576 L463 575 L460 574 L457 573 L454 572 L452 572 L449 571 L446 570 L443 569 L440 568 L437 567 L435 566 L432 565 L429 564 L426 563 L423 562 L421 560 L418 559 L415 558 L412 557 L409 556 L407 555 L404 554 L401 553 L398 551 L396 550 L393 549 L390 548 L387 546 L385 545 L382 544 L379 543 L377 541 L374 540 L371 539 L368 537 L366 536 L363 535 L360 533 L358 532 L355 530 L352 529 L350 528 L347 526 L345 525 L342 523 L339 522 L337 520 L334 519 L331 517 L329 516 L326 514 L324 513 L321 511 L319 510 L316 508 L313 507 L311 505 L308 504 L306 502 L303 500 L301 499 L298 497 L296 495 L293 494 L291 492 L288 491 L286 489 L283 487 L281 486 L278 484 L276 482 L273 481 L271 479 L268 477 L266 475 L263 474 L261 472 L258 470 L256 468 L254 467 L251 465 L249 463 L246 461 L244 460 L241 458 L239 456 L237 454 L234 452 L232 451 L230 449 L227 447 L225 445 L222 443 L220 441 L218 440 L215 438 L213 436 L211 434 L208 432 L206 430 L204 428 L201 426 L199 425 L197 423 L194 421 L192 419 L190 417 L187 415 L185 413 L183 411 L180 409 L178 407 L176 405 L174 404 L171 402 L169 400 L167 398 L164 396 L162 394 L160 392 L158 390 L155 388 L153 386 L151 384 L149 382 L146 380 L144 378 L142 376 L140 374 L137 372 L135 370 L133 368 L131 366 L128 364 L126 362 L124 360 L122 358 L120 356 L117 354 L115 352 L113 350 L111 348 L109 346 L106 344 L104 342 L102 340 L100 338 L98 335 L95 333 L93 331 L91 329 L89 327 L87 325 L84 323 L82 321 L80 319 L78 317 L76 315 L74 313 L71 311 L69 309 L67 307 L65 304 L63 302 L61 300 L58 298 L56 296 L54 294 L52 292" stroke="#319BBC" stroke-width="43.33813086152077" fill="none"></path>
</svg>
</div>
),
{
width: 1200,
height: 675,
},
);
}
This is how that image looks once rendered. You can see we’re able to render the background without any additional logic and importantly without having to resort to using Puppeteer to take a screenshot. This means it’s pretty portable to other services. There’s a whole bunch of other examples on how it can be used in Vercels docs.
Self-Hosting as a Supabase Edge Function
Supabase recently released their Edge Runtime for functions which is able to be self hosted. It’s a wrapper around Deno and makes deployment to your preferred provider dead simple. Since it’s already configured, I decided to use their example repo as the basis for deploying my new image OpenGraph API to fly.io.
I’ve created a new ‘og’ folder in the functions folder for my service and added a handler file containing all the work done so far. I also added an index file to serve this handler and set it to a port unused by any other function:
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
import handler from './handler.tsx'
console.log('Hello from og-image Function!')
serve(handler, { port: 9010})
Any imports you use within your function need to be from remote CDN’s that the runtime will pull from when built so I updated all of mine in my handler accordingly.
import { createNoise3D } from "https://esm.sh/simplex-noise";
import Alea from "https://esm.sh/alea";
import { ImageResponse } from 'https://deno.land/x/og_edge/mod.ts';
import React from "https://esm.sh/react@18.2.0";
I decided I’d like to be able to change the seed value on demand (rather than being bound to the title), so made that a parameter. This gives me the option of changing the whole image just by supplying a new value.
It’s also recommended by Supabase that you update your docker image to the latest version of the Supabase runtime. You can find a list of releases here.
You’ll need both the CLIs for Supabase and fly.io installed to run supabases and my code. With Supabase running, you can test functions locally prior to deployment using supabase functions serve
.
Once I’d confirmed everything was working ok, I was ready to deploy. I ran fly launch
which asks you a few questions about how you’d like it deployed on fly.io and will build the Dockerfile and deploy it to a region of your choice closest to your users.
Once deployed, requests to my new service and it’s respective images now looked like:
https://{your-app-name}.fly.dev/og?title=Self%20Hosting%20Generative%20Open%20Graph%20Art%20on%20the%20Edge%20with%20Supabase&subtitle=ianwootten.co.uk&seed=111
Final Thoughts
Now I have a remote service that I can host anywhere to generate beautiful Open Graph images. I could put the URL for this within the metadata for each of my articles - but instead I chose to generate the image, download it and commit it back to my blogs repo with every new post I write. This way the bandwidth for these functions are managed by Github pages (my host), rather than unwittingly racking up a costly bill with fly.io should a post ever go viral (a man can dream!). It also allows me to use one-off custom images on every post should I want to.
I’m hoping to make more generative art soon - but hopefully understanding the concepts behind it a little more deeply. It’s highly likely I’m going to be playing with other 2D geometric images with SVG using this approach and maybe experiment with Supabase storage to cache the images created. If you’re interested in generating some yourself, be sure to check out all the code for this article which I’ve made available in my functions repo.