I was working on a Nuxt website which had a portfolio of websites I’d built. Each project had a title, description, link to the website and an image showing what it looked like.
The problem was keeping those images up to date.
Every time a website changed, I’d have to take another screenshot, crop it and upload it to WordPress. It wasn’t particularly difficult, but it was repetitive work that I didn’t really want to keep doing.
The website was already being generated statically using Nuxt, so I wondered whether I could automate the screenshots as part of the build.
Rather than maintaining a collection of images, I could give the application a URL and let it take the screenshot for me.
Using Puppeteer
I decided to use Puppeteer
It can open websites, interact with elements and take screenshots, all without needing to display a browser window.
For this project, I used puppeteer-core alongside chrome-aws-lambda, which provided a version of Chromium suitable for running in a serverless environment.
The browser was configured with a fixed viewport size so that screenshots would be consistent.
1const chromium = require('chrome-aws-lambda') 2 3const browser = await chromium.puppeteer.launch({ 4 executablePath: await chromium.executablePath, 5 args: chromium.args, 6 defaultViewport: { 7 width: 1920, 8 height: 1240, 9 deviceScaleFactor: 110 },11 headless: chromium.headless12})
This gives Puppeteer a browser running at a resolution of 1920 × 1240.
I deliberately used a desktop resolution because the screenshots were going to be displayed inside desktop and laptop mockups on the website.
Taking the screenshot
Once Chromium was running, the next step was fairly straightforward.
Open a new page, navigate to the URL and take a screenshot.
1async function screenshotHandler (url) { 2 const page = await browser.newPage() 3 4 await page.goto(url, { 5 waitUntil: ['load'], 6 timeout: 30000 7 }) 8 9 const screenshot = await page.screenshot({10 type: 'jpeg',11 encoding: 'base64',12 quality: 8013 })14 15 await page.close()16 17 return screenshot18}
I used JPEG with a quality setting of 80, rather than PNG, to keep the image size down.
The screenshot was returned as a Base64 string, which meant I could put it directly into an image element without having to save it to a separate file.
The image could then be displayed like this:
1<template>2 <img3 :src="'data:image/jpeg;charset=utf-8;base64,' + post.imgThumb"4 :alt="post.title"5 loading="lazy"6 >7</template>
It worked, but there was a problem.
Waiting for websites to finish loading
Just because a browser has fired its load event doesn’t necessarily mean the website is ready for a screenshot.
A lot of websites load content using JavaScript after the initial HTML has finished loading.
Images might still be appearing, animations might be running, or a cookie banner might pop up over the page.
I found that taking the screenshot immediately after navigation could produce incomplete results.
My solution was fairly simple: wait another five seconds before capturing the page.
1const delay = ms => new Promise( 2 resolve => setTimeout(resolve, ms) 3) 4 5await page.goto(url, { 6 waitUntil: ['load'], 7 timeout: 30000 8}) 9 10await delay(5000)
It’s not the most sophisticated approach. Some websites might be ready much sooner, while others could still be loading after five seconds.
But for the relatively small number of websites I was capturing, it was a practical solution.
Dealing with cookie banners and popups
The next problem was that several screenshots contained cookie banners or other popups.
This wasn’t particularly surprising. Puppeteer was opening each website in a fresh browser page, so there was no previous browsing session or cookie consent stored.
I didn’t want my portfolio images showing a massive cookie banner covering half the website.
Fortunately, Puppeteer can interact with elements before taking the screenshot.
I added checks for the popup buttons used by some of the websites.
1if (await page.$('.cookie-accept')) {2 await page.click('.cookie-accept')3}4 5if (await page.$('.popup-close')) {6 await page.click('.popup-close')7}8 9await delay(1000)
These are simplified selectors, but the original implementation used the same approach with selectors for the particular popup plugins I encountered.
If a matching button existed, Puppeteer would click it before taking the screenshot.
I also added another one-second delay to allow any popup animations to finish.
This isn’t a universal solution, as every website can implement cookie consent and popups differently. But it handled the specific cases I needed.
Running it during the Nuxt build
The website was using Nuxt 2 with static generation, pulling its content from WordPress through GraphQL.
The portfolio entries were already stored in WordPress, including a field containing the website URL.
I created a custom Nuxt module to fetch that information and generate the screenshots during the build.
1const browser = await chromium.puppeteer.launch({ 2 executablePath: await chromium.executablePath, 3 args: chromium.args, 4 headless: chromium.headless 5}) 6 7for (const post of portfolioArr) { 8 post.imgThumb = await screenshotHandler( 9 encodeURIComponent(post.ACFLink.link)10 )11}12 13await browser.close()
The important part is that this happens before the static pages are generated.
The screenshots become part of the data used to render the website, rather than something fetched when somebody visits a page.
I also made sure to launch Chromium only once, rather than creating a new browser instance for every website.
Each screenshot gets its own page, but they all share the same browser process.
Starting a browser is relatively expensive, so reusing the instance avoids unnecessary overhead.
Allowing manual images as a fallback
There are times when an automatically generated screenshot isn’t what you want.
Perhaps the website has changed, the screenshot doesn’t capture the most interesting part of the design, or you simply want a more carefully composed image.
I didn’t want to lose the option of uploading an image manually.
The portfolio entries already had a featured image field in WordPress, so I updated the frontend to use that image when one was available.
1<template> 2 <img 3 v-if="post.featuredImage.node.mediaItemUrl" 4 :src="post.featuredImage.node.mediaItemUrl" 5 :alt="post.title" 6 loading="lazy" 7 > 8 9 <img10 v-else11 :src="'data:image/jpeg;charset=utf-8;base64,' + post.imgThumb"12 :alt="post.title"13 loading="lazy"14 >15</template>
This gave me the best of both approaches.
If I uploaded a featured image, it would be used. Otherwise, the website would display the screenshot generated by Puppeteer.
What about failed screenshots?
Not every website is guaranteed to load successfully.
A website could be offline, block automated browsers or take too long to respond.
I wrapped the screenshot process in a try/catch, with a timeout on navigation and an SVG placeholder to use when something failed.
That prevented a single unavailable website from stopping the screenshot process entirely.
It also meant the portfolio could still render an image rather than displaying a broken element.
Was it worth it?
For a relatively small feature, there’s quite a lot happening behind the scenes.
The build has to start Chromium, visit each website, wait for it to load, dismiss any popups, capture the image and then close the browser.
It also makes builds slower, because the screenshots are generated sequentially, and each one includes a delay.
But the original implementation solved the problem I had.
I no longer needed to manually capture a screenshot every time I wanted to add a website to the portfolio. I could just provide the URL and let the build take care of it.
It’s a good example of using a headless browser to automate something that would otherwise be a fairly boring manual task.