Og images are important for any blog or website. They are the share image that is shown when you include a url to your site on social media.
Astro makes it easy to include og images within its SEO package but making these images dynamic takes a little more set up.
In this lesson we'll set up an Astro API route returns a link to a dynamic image utilizing satori
and @resvg/resvg-js
to do so.
The was we set it up, we'll create different templates that the API endpoint will accept and each template can render different end points so you could have a default template, one for your blog posts, and possibly another for your about page.
During this process, you'll learn how to pass data from the client to the server through URL Search Params which will power the dynamic portion of the image generation
Instructor: [0:00] Open Graph images, or OG images for short, are an important aspect of every blog page. They're the images that you see when someone shares an article on social media.
[0:11] Having a static OG image is fairly straightforward to implement using the astro-seo package, but making the OG image dynamic has its challenges.
[0:21] In this lesson, we're going to learn how to implement Dynamic OG images for all of our pages. Let's start by installing the packages that we're going to be using. We're going to npm install satori, add resvg/resvg-js, and the satori-html packages. There we go.
[0:44] We mentioned that instead of serving an actual static image, we're going to be serving a Dynamic OG image, which means we need a custom dynamic endpoint. We're going to make the endpoint seem like a static image, similarly to how we made the feed.xml file seem like you're accessing an actual XML file.
[1:03] We're going to create a new folder called OG in the pages directory, and we're going to create a new file, which is going to be a dynamic route where the segment is template.Png.ts. We're going to be defining different templates for our pages. That's why we need it to be a dynamic endpoint.
[1:23] Just like we did in the previous lesson, we need to export the prerender constant set to false in order to treat this endpoint as a server-side render. Of course, we're going to export an async function named get and obtain the context, which had the API context type. Let's also import that one.
[1:45] In order to obtain the dynamic segments, we destructured them from the context.params property. When they didn't exist, we returned a new response where the message was "Bad Request" and the status was set to 400, to indicate the client that they're making a wrong request.
[2:05] What's even a better idea is instead of returning just a bad request, we can return a specific message, something like "Must provide a template." This is a much more useful message than the bad request one.
[2:19] Now since we have the template value, we can start creating the template. In the SRC directory, we're going to create a new folder called OG Templates, and we're going to create a new file inside called templates.ts.
[2:33] We're going to export a new constant called templates that has the type of record where the key type is a string, and the value is a function that returns any, and optionally accepts a data argument, which is also going to be a record where the key is string and the value is any.
[2:55] This might look like a more complex type, but basically, the templates constant is going to be a record of templates where the key is going to be the string, which is going to be the template name, and the value is going to be a method that returns any but optionally accepts a data attribute, which is pretty much a plain object.
[3:15] We can now start defining the templates. Let's define the default template first. As we defined, it's going to be a function that's going to return something. That something is going to be the html method from the satori-html package. Let's import that -- import { html } from "satori-html."
[3:35] The html method accepts a template string that has HTML syntax inside, which means we can open backticks and put a div inside. Additionally, the package also supports Tailwind classes, which we can set with the TW attribute.
[3:51] Let's make our div a flex with items set to center, justify, set to between, and we'll add a padding of 24 units. Inside of the div, we can add an
[4:04] Element with our own name as the value and maybe even a paragraph with our title. This is good enough for our first template.
[4:13] Let's try to use it now. We can go back to our template.Png.ts file and create a new constant called templateFn, which stands for template function. We're going to access our template's records and we're going to pass the template from the context.
[4:30] Right now we only have the default template, but what if we pass a template that doesn't exist? Again, we need to create a new check for the template function. If it doesn't exist, we'll just return a new response saying "Template not found," and we're going to set the status to 404, which is the HTTP status code for not found.
[4:51] Now, since we have the template, let's import satori as a default import from the Satori package. We're going to use satori to generate SVG from our template. Let's create a new constant called svg. We're going to assign it to the satori function, where the first argument is the template function, and we're also going to invoke it.
[5:16] Then we're going to provide an object as the second argument with the width and height attributes. For an OG image, 1200x630 is a recommended resolution. This object is also going to need value for the fonts, which accepts an array of objects.
[5:34] Let's define the Inter font. We'll pass the name as inter, we'll set the style to normal. Now we need to bring in the actual file, which means we need to add the TTF font file to our project. I pasted the inter-regular.ttf font right next to the templates file into the OG Templates directory. You can visit the GitHub repository of this course if you want to download the same font file.
[5:59] Now, in order to read the font file, we need to import fs, the file system functions, from fs/promises.
[6:09] We also need to import the path function from the path package.
[6:14] Using fs and path, we need to provide the font data. We're going to do await fs.readFile. We're going to pass path.join(), where the first segment is going to be the process.cwd(), which stands for current working directory. In our case, it's going to post at the root of our project.
[6:35] For the second argument, we need to pass src/og-templates/inter-regular. Let's do that. We're going to pass src/og-templates/inter-regular.ttf. That's how we can define the font in Satori.
[6:52] With this, we're now generating an SVG image out of our template. Now we need to convert that SVG into a Png before we return it. First, we're going to import resvg from the resvg package. I'm going to scroll down. After we obtain the SVG, we're going to create a new constant named resvg-instance, and we're going to create a new instance.
[7:15] We're going to pass the SVG as the first argument, and then we're going to pass a configuration object. The configuration object tells resvg the size of the Png image that we want to generate.
[7:26] We're going to define the fitTo property. We're going to set it to an object that has a mode value set to width, and a value value set to 1200. This basically tells resvg to fit the image to a box that has a width of 1200 pixels. Of course, because Satori returns a promise, we forgot to await it. Let's go back and await the Satori so that our SVG is actually a string. Now we have the resvg instance.
[7:57] In order to create an image out of it, we need to render it. Let's create a new constant called image. We're going to assign it to await resvg-instance.render. From the resvg instance, we're rendering the image.
[8:12] Lastly, we need to return a new response where the data is going to be image.spng. The sPng method returns a buffer, which is accepted by the response class. This is a way to return a file as a result from our endpoint.
[8:31] Now we can test it out. If we run the server, we'll be met with an error saying, "No loader is configured for .node files." It points to the resvg-darwin-arm64.node file. This error happens because Vite tries to optimize the resvg library by default, but it fails to do so. We can use the library without optimizing it.
[8:53] Let's fix that. Let's open our astro.config file. Below the adapter, define the Vite configuration object. We're going to define optimizeDeps property, and then the exclude. This is going to be an array of package names, so we'll pass @resvg/resvg-js. This is going to prevent Vite from optimizing the resvg package.
[9:19] Now if we run the server again, we'll see that there are no errors. Let's open the localhost on 4321, then we'll open /og, and then /default.Png, which hits our dynamic endpoint. There we go. There is our template generated into an image. There's our name at the top left, and also there's our title at the top right.
[9:42] Now we're ready to implement it to our website. We want all of our pages to include an OG image. Let's open our shared layout file. Instead of the props interface, let's define an optional OG image field, which is going to be an object that has a template string inside.
[10:00] That's how we're going to specify which template we want to use for each of the pages. Having the template now, we can start defining the og:image URL. Let's create a new constant called og:image URL. We're going to assign that to a template string.
[10:15] We're going to pass astro.url.origin as the first segment, which is going to post to localhost:4321 for development and the actual URL for our production deployment. We're going to follow by /og/ and then another expression, which is going to use the og:image prop, but first, let's destructure it. There we go. We'll use the og:image, but we know that it's an optional.
[10:42] We'll add question mark, then .template if the page provides its own template, otherwise, we'll just use the default one. Then outside of the expression, we can do .Png, and that's our og:image URL. Now we can actually use it in our SEO options. Let's scroll down. Let's define the open graph property, and we're going to want to set the image property.
[11:04] At this time, we can't just set the image property because the basic property is also required, so we have to provide the required properties aside from setting the image to og:image URL. Let's do that first. This is not optimal, but it's something that we have to do. Let's set the basic property first.
[11:22] Let's set the type to Website, set the title to the SEO props that we have .title, or a fallback value of lasers personal blog. Now we can set the image to our og:image URL, and lastly, the URL, which we can set to seo.canonical. There we go.
[11:44] Additionally, we can also set the Twitter object, we can set the cards to the value of summary_large_image, and this tells Twitter that we want to render a large-image cards for our articles, and we're going to set the image property to our og:image URL. There we go. We have a mechanism that sets the og:image and Twitter image to all of our pages.
[12:10] If we go back and open the inspect element, we can open the head tag, and we're going to see the og:image meta tag that points to localhost/og/default.Png. If we deploy this at this point, and share our website on Twitter or other social media platforms, the og:image will show up.
[12:30] We're ready to implement a different template that's going to show the title of every blog post dynamically, but before we proceed, we've learned a lot, so it's a good idea to recap everything. To generate our og:image, we first created an endpoint masked into a static resource that ends with a .Png.
[12:49] We made our endpoint support multiple templates, so we made its routes dynamic. We used this satori-html package to define our default template, and then we use the satori and resvg packages to convert it to SVG and then Png.
[13:05] We also needed to bring in the inter-font with the FS and path packages, so Satori knows how to render text. At the end, we just return the image as Png result, which is a buffer of the actual Png image. In order to use it, we defined a new template prop in our layout file props, constructed the og:image URL to point to our new endpoint.
[13:29] Then we set the open graph and Twitter props of the SEO component to include our new image along with a few other SEO properties. This added our og:image to all of our pages. Now, let's see how we can make it dynamic. We already have the mechanism of templates that accept arbitrary data.
[13:48] The image generation happens on the server side, while the template choosing happens on the client side through the props of the layout component. Since we're making that choice only with the URL, we can pass the data as search parameters to that query.
[14:03] Let's define an optional data property after the template, and we can set it to a record of string and any. We're going to append a question mark after the .Png extension, and then open an expression which creates a new instance of the URLSearchParams class and we'll pass the og:image.datavalues.
[14:28] The URLSearchParams class will convert these records of values into query parameters as a string. This is all we need to do to carry the data from the client side to the server side. Let's use this in our article page. Let's go to blog/slug. We're going to add the og:image prop to the layout component.
[14:47] We'll set the template to blog and then we can set the data to an object that has a title set to post.data.title, which is the current blog post's title. We don't have the blog template defined just yet, but that's OK, we're going to do it later.
[15:03] See what this does. Let's open the Astro's content collections API blog post and open the inspect element. In the head tag, we're going to see the og:image, but this time the URL is going to point to the og/blog.Png and it's going to have a title search parameter set to Astro content collections API, which is URL encoded. Cool.
[15:26] It's time to parse this on the server side. Let's go back to our endpoint. After we check if the template function exists, we're going to obtain the data. The context object has access to the requests, which holds the URL of the requests as a string. This is the URL, which is the og:image URL that also contains the search parameters.
[15:46] A quick way to obtain them is to create a new URL instance from the request.url and destructure the SearchParams from that URL. This line is going to grab the search parameters as a string, but it's going to convert them into a URL search parameters type. Now we need to convert the URL search parameters type to a plain object type.
[16:11] In order to do that, let's create a new constant called data, and we're going to assign it to the object. from entries result. We're going to pass SearchParams.entries inside. We need to do this because the URL search parameters implements the Iterable iterator interface. This is a quick and easy way to convert all of its entries into a plain key-value object.
[16:38] All right. Since we have the data, we can just pass it to the template function as an argument. With this, we're done with the data transfer mechanism. The only thing that's left to do is to create a new template that actually uses the data. Let's open the templates record now and below the default, we're going to create the blog template.
[16:56] This time, we're going to accept the data as an argument and we're going to return an HTML just like we did for the default. In fact, let's just duplicate the default template. To see that it works, let's delete the H1, open a new expression, and try to obtain the data.title property.
[17:14] In a real-life scenario, you would spend a little more time designing the template. The data transfer mechanism that we just built allows you to pass any form of data as long as it can be serialized into a string.
[17:27] We can parse the title, you can parse the description, you can parse a Boolean or a number, etc. Let's see the actual image. If we copy the og:image URL for the Astro's content collections API blog post, look at that.
[17:42] Astro's content collections API, and we still have the paragraph, but that's OK. Let's open a different blog post. Let's open Astro Rocks. Grab the og:image URL, and there we go, we have Astro Rocks. This is pretty awesome. We now have Dynamic OG images, and we are done.
[17:59] Let's do a full recap now. In this lesson, we learned how to generate an OG image for each of our pages. We started by creating a GET endpoint that had a dynamic route with a template segment.
[18:12] We parsed the template through the context and obtained it from our record of templates. We also parsed the search params data back into an object. We built the templates with the satori-html package, which allows us to use tailwind classes.
[18:27] To generate the image, we passed the template to Satori, which generated an SVG for us. Then we passed the SVG to the resvg package that generated the Png image for us.
[18:38] At the end, we returned the Png image back to the client. We modified our layout component to include an og:image prop that defined the template and an optional data record.
[18:50] Using the Astro.url.origin, the template, and the data, we constructed the OG image URL and used it in the SEO component so it gets injected into the head tag for every one of our pages. This allowed us to define the template and the optional data for each type of page.
[19:09] We also needed to prevent Vite from optimizing the resvg package, so we excluded it in our Astro config file. The result was a fully functional OG image generator that supports different templates and can accept arbitrary data from our pages.