A "little" side project – New Hugo based website – YummyRecipes.uk

I have made yet another Hugo based website that I want to share with the community.

It’s Yummy Recipes UK - Nut-Free cooking & baking

You can read a full post about it, with a bit of extra content on:
A “little” side project – New Hugo based website – YummyRecipes.uk, or just technical aspect posted below.


Technical aspect

Website is built with Hugo[1] and is hosted on Netlify. The source code is hosted on GitHub.

The layout is built from scratch with HTML, CSS and minimal JavaScript. Content is stored in Markdown files with a bit of header required by Hugo.

It contains Search, which is done in the user browser and not on the server, using index.json generated when the site is deployed.

Contact form is done through Netlify Forms.

It offers automatic Dark Mode and friendly printing for desired recipes. Any similar recipes are presented in the “related” section. If you would like something, you can easily share it on desired social media, or by e-mail using handy share icons in the post footer.

A massive thing was put into Rich Results, Schema for Recipes and OpenGraph.

Visitors are counted through my minimal Google Analytics 4 snippet. Apart from that nothing else is tracked. In its current form, all content is available for free, without ads but with copyright rights incorporated.

Private users are free to use them (print) for easy, hustle free cooking and baking.

If you want to be always up to date, add its RSS feed to your Feed Reader (like Feedly).


Website Grader


  1. Hugo is the world’s fastest framework for building websites. One of the most popular open-source static site generators. With its amazing speed and flexibility, Hugo makes building websites fun again. ↩︎

12 Likes

Looks great! I love that the recipe ingredients are front and center, and each one gets right into the recipe, with none of the fiction and fluff that so many recipe sites have these days. Good work, and I hope you rank higher than those other sites.

What did you use for search? I used a similar setup in the past with index.json and Lunr.js.

2 Likes

Thank you.

For search I just used described on this forum great solution without any heavy Js library.

2 Likes

Looks nice. Though the menu is unnecessary in the print version. Do you have the theme as public? I don’t have a use case yet, but a family member is a chef and this might encourage her to blog if I set up a quick site for them.

1 Like

And I would like not to include images when printing :wink:

I like the clean & simple layout of the site.

1 Like

Yep, the menu shall be hidden. Missed that :slight_smile: will fix. Thanks for suggestion.

fixed

The layout is custom made and is not public, sorry.

Thanks, will need to think about it. Some people would like but you are right, when you making it you already saw it on screen hence images will not be needed. Will do some changes :slight_smile:

changed

Agree. I don’t mind images too much as long as everything is on one page. I looked in to the Tailwind print:hidden class, but I think that can’t include options, like print images yes/no. Anyway, a minor point.

1 Like

On top of a really excellent design, you’ve integrated the recipe schema and done all the great SEO work. Bravo!

The only recommendation I can give here is to use bowls for serving icons instead of cutlery. thanks
Screenshot 2022-04-24 at 2.42.28 PM

1 Like

Good idea, will have a look on that.

What do you mean recipe schema? How can you tell?

I’m in the process of making a recipe site too and have heard of this schema but haven’t seen any good functional descriptions of it or implementations, at least in Hugo.

Also, how can you tell the SEO is great? I’m curious how to evaluate all these without the theme to look at.

It is a nicely done website, i agree.

1 Like

you can read more aout it here Recipe - Schema.org Type

i will not respond to how good seo is implemented as it will be unrelevant here.

hugo is really good at making use of schema like this:

1. <div itemscope itemtype="https://schema.org/Recipe">

2. <span itemprop="name">Mom's World Famous Banana Bread</span>

and replace it with

1. <div itemscope itemtype="https://schema.org/Recipe">

2. <span itemprop="name">{{ .Title }}</span>

hope that helped thanks

1 Like

Recipe schema is the bits that are not visible by you or the user but are by Search Engines. Type any recipe in google and where you will see featured image, cooking times etc. this is taken from schema.

See Understand How Structured Data Works

For validity, you use Rich Result Test.

Apart of Schema, important is OpenGraph and Breadcrumbs. Have look on mine from this project:

Schema (Posts, Recipes and Breadcrumb)

<script type="application/ld+json">
{{ if eq .Title .Site.Title }}
    // homepage
    {
      "@context": "https://schema.org/",
      "@type": "WebPage",
      "name": "{{ .Title }}",
      "url": "{{.Site.BaseURL}}",
      "inLanguage": {{ .Site.LanguageCode }},
      "isFamilyFriendly": "true",
      "copyrightYear": "{{ .PublishDate.Format "2006" }}",
      "copyrightHolder": {{ .Site.Params.author }},
      "accountablePerson": {
        "@type": "Person",
        "name": {{ .Site.Params.author }},
        "url": "{{.Site.BaseURL}}"
      },
      "author": {
        "@type": "Person",
        "name": {{ .Site.Params.author }},
        "url": "{{.Site.BaseURL}}"
      },
      "creator": {
        "@type": "Person",
        "name": {{ .Site.Params.author }},
        "url": "{{.Site.BaseURL}}"
      },
      "publisher": {
        "@type": "Organization",
        "name": {{ .Site.Params.author }},
        "url": "{{.Site.BaseURL}}",
        "logo": {
          "@type": "ImageObject",
          "url": {{ .Site.Params.logo_sq_png | absURL }}
        }
      },
    }
{{ else if .Params.recipe }}
    // schema for recipes
    {
         "@context": "https://schema.org/",
         "@type": "Recipe",
         "mainEntityOfPage": { "@type": "WebPage" },
         "name": {{ .Title }},
         {{ if .Params.featuredImage }}"image": {{ .Params.featuredImage | absURL }},{{ end }}
         "author": {
           "@type": "Person",
           "name": {{ .Site.Params.author }}
         },
         "datePublished": {{ .PublishDate }},
         "dateModified": {{ .Lastmod }},
         "description": {{ .Description }},
         "recipeCuisine": {{ .Params.recipeCuisine }},
         "prepTime": {{ .Params.prepTime }},
         "cookTime": {{ .Params.cookTime }},
         "totalTime": {{ .Params.totalTime }},
         "keywords": {{ .Params.tags }},
         "recipeYield": [
            {{ .Params.recipeYield }}
          ],
         "recipeCategory": {{ range last 1 .Params.categories }}{{ . }}{{ end }},
         "nutrition": {
           "@type": "NutritionInformation",
           "calories": {{ .Params.calories }}
         },
         "aggregateRating": {
           "@type": "AggregateRating",
           "ratingValue": "5",
           "ratingCount": "25"
         },
         "recipeIngredient": [
          {{ .Params.recipeIngredient }}
         ],
            "recipeInstructions": [
              {
                "@type": "HowToStep",
                "name": "Method",
                {{ if .Params.featuredImage }}"image": {{ .Params.featuredImage | absURL }},{{ end }}
                "text": "{{ .Params.recipeInstructions }}",
                "url": "{{ .Permalink }}#method"
              }
            ]
       }
{{ else }}
    // schema for posts
    {
        "@context":"http://schema.org",
        "@type": "BlogPosting",
      "mainEntityOfPage": { "@type": "WebPage" },
      "headline": {{ .Title | truncate 110 }},
        {{ if .Params.featuredImage }}"image": {{ .Params.featuredImage | absURL }},{{ end }}
      "image": {{ .Params.logo_sq_png | absURL }},
        "url": {{ .Permalink }},
      "datePublished": {{ .PublishDate }},
      "dateModified": {{ .Lastmod }},
        "inLanguage": {{ .Site.LanguageCode }},
        "isFamilyFriendly": "true",
        "copyrightYear": "{{ .PublishDate.Format "2006" }}",
        "copyrightHolder": {{ .Site.Params.author }},
        "accountablePerson": {
            "@type": "Person",
            "name": {{ .Site.Params.author }},
            "url": "{{.Site.BaseURL}}"
        },
        "author": {
            "@type": "Person",
        "name": {{ .Site.Params.author }},
            "url": "{{.Site.BaseURL}}"
        },
        "creator": {
            "@type": "Person",
        "name": {{ .Site.Params.author }},
            "url": "{{.Site.BaseURL}}"
        },
        "publisher": {
            "@type": "Organization",
        "name": {{ .Site.Params.author }},
            "url": "{{.Site.BaseURL}}",
            "logo": {
                "@type": "ImageObject",
                "url": {{ .Site.Params.logo_sq_png | absURL }}
            }
        },
        "articleBody": {{ .Content | safeJS | htmlUnescape | plainify }},
      "description": {{ with .Description }}{{ . | plainify }}{{ else }}{{if .IsPage}}{{ .Summary | plainify | safeHTML }}{{ else }}{{ with .Site.Params.description }}{{ . | plainify }}{{ end }}{{ end }}{{ end }},
      "keywords": [{{ range $i, $e := .Params.tags }}{{ if $i }}, {{ end }}{{ $e }}{{ end }}]
    }
{{ end }}
</script>
    
{{ if .IsHome }}
<script type="application/ld+json">
      {
        "@context": "https://schema.org",
        "@type": "Organization",
        "url": "{{.Site.BaseURL}}",
        "logo": "{{ .Site.Params.logo_sq_png | absURL }}"
      }
</script>
{{ end }}
    
{{ if ne .Title .Site.Title }}
<script type="application/ld+json">
    
    //breadcrumb schema
    {{ if .Params.categories }}
        {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [{
            "@type": "ListItem",
            "position": 1,
            "name": "Home",
            "item": "{{.Site.BaseURL}}"
        },
        {{ range $i, $e := .Params.categories }}
        {
            "@type": "ListItem",
            "position": 2,
            "name": {{ $e }},
            "item": "https://yummyrecipes.uk/{{ $e | urlize }}"
        },
        {{ end }}
        {
            "@type": "ListItem",
            "position": 3,
            "name": {{.Title}}
        }]
        }
    {{ else }}
        {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [{
            "@type": "ListItem",
            "position": 1,
            "name": "Home",
            "item": "{{.Site.BaseURL}}"
        },{
            "@type": "ListItem",
            "position": 2,
            "name": {{.Title}}
        }]
        }
    {{ end }}

</script>
{{ end }}

OpenGraph

<meta property="og:title" content="{{ .Title }}">
<meta property="og:description" content="{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end }}">
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:locale" content="{{ .Site.LanguageCode }}">

{{ if .Params.featuredImage }}
  <meta property="og:image" content="{{ .Params.featuredImage | absURL }}">
{{ else }}
  <meta property="og:image" content="{{.Site.BaseURL}}{{ .Site.Params.og_image }}"> <!-- use 1.91:1 minimum 1200x630 -->
{{ end }}

{{- $iso8601 := "2006-01-02T15:04:05-07:00" -}}
{{- if .IsPage }}
{{- if not .PublishDate.IsZero }}<meta property="article:published_time" {{ .PublishDate.Format $iso8601 | printf "content=%q" | safeHTMLAttr }}>
{{ else if not .Date.IsZero }}<meta property="article:published_time" {{ .Date.Format $iso8601 | printf "content=%q" | safeHTMLAttr }}>
{{ end }}
{{- if not .Lastmod.IsZero }}<meta property="article:modified_time" {{ .Lastmod.Format $iso8601 | printf "content=%q" | safeHTMLAttr }}>{{ end }}
{{- else }}
{{- if not .Date.IsZero }}<meta property="og:updated_time" {{ .Lastmod.Format $iso8601 | printf "content=%q" | safeHTMLAttr }}>
{{- end }}
{{- end }}{{/* .IsPage */}}

{{- with .Params.audio }}<meta property="og:audio" content="{{ . }}">{{ end }}
{{- with .Site.Params.title }}<meta property="og:site_name" content="{{ . }}">{{ end }}
{{- with .Params.videos }}
{{- range . }}
<meta property="og:video" content="{{ . | absURL }}">
{{ end }}{{ end }}

{{- /* If it is part of a series, link to related articles */}}
{{- $permalink := .Permalink }}
{{- $siteSeries := .Site.Taxonomies.series }}{{ with .Params.series }}
{{- range $name := . }}
  {{- $series := index $siteSeries ($name | urlize) }}
  {{- range $page := first 6 $series.Pages }}
    {{- if ne $page.Permalink $permalink }}<meta property="og:see_also" content="{{ $page.Permalink }}">{{ end }}
  {{- end }}
{{ end }}{{ end }}

{{- if .IsPage }}
{{ with .Site.Author.facebook }}<meta property="article:author" content="https://www.facebook.com/{{ . }}">{{ end }}
{{ with .Site.Social.facebook }}<meta property="article:publisher" content="https://www.facebook.com/{{ . }}">{{ end }}
<meta property="article:section" content="{{ .Section }}">
{{- with .Params.tags }}{{ range first 6 . }}<meta property="article:tag" content="{{ . }}">{{ end }}{{ end }}
{{ end }}

{{- /* Facebook Page Admin ID for Domain Insights */}}
{{- with .Site.Social.facebook_admin }}<meta property="fb:admins" content="{{ . }}">{{ end }}

ps. I personally prefer to use JSON type of schema instead Microdata like @pitifi9191 shown in his example.

4 Likes

Amazing response, thank you for the resources and pointers.

So having this in each page as appropriate (I assume it’s in the head of the html?) benefits search robots and other social aspects? In the actual content portion of the page, is any labeling used or it’s only really needed/useful as part of the structured data section? Looking at the schema website examples at the bottom, it looks like if you do the Microdata method, it is part of the content, but the JSON type is in a script tag… likely in the ? And the content still needs to be separately displayed and styled?

Its just a partial loaded in the head just before </head>

  {{ partial "schema.html" . }}

and based on the type of page (homepage, page, or recipe) loading generating appropriate content.

These are like boosters. Instead of relying on Google and others to discover what your page is presenting, you are providing a known language for them in a format that they accept and they do the rest. It’s like OpenGraph for social media.

The content (markdown file) contain headers that are user later on relevant parts of schema etc.

Here is archetype for recipe.md

---
title: "{{ replace .Name "-" " " | title }}"
date: "{{ .Date }}"
lastmod:
url: /.../
description: "Description of this recipe"
categories:
 - 
tags: 
 - 
featuredImage: "/images/2022/02/image.png"

recipe: true
recipeCuisine: American
prepTime: PT20M
cookTime: PT20M
totalTime: PT20M
recipeYield:
 - 'integrer'
 - 'custom unit ie 12 cookies'
calories: 270
recipeIngredient:
 - 
recipeInstructions: "..."
---

## Ingredients

- ...

or 
### Base 
- ...

## Method

...

## Tips

... (optional)

As you will notice, there is a lot of information pasted in the header of the markdown and the markdown content is strictly ingredients, methods and tips.

Off course, then some of these bits are taken into the layout, not only schema.

Microdata is more integrated with the HTML part of the website. It’s easier to do this in JSON, as a separate bit, as in my opinion, I don’t need to adjust the layout of the website elements to include microdata. It’s more difficult to make it work smoothly with markdown files, hence I found JSON easier. Google (from the article mentioned before) also prefer this in that format.

Overall, content is kept in the markdown file after --- and all between these parts (header of the markdown file) is used in various parts of the website (schema but not only). Hugo during the build “magically” uses all that is required together.

This is a nice thing with Hugo. Once you got everything designed and working, you can concentrate on the content (recipes in that instance). Following archetypes format, you just do what’s needed, publishing content.

The content of the website is not written by me, it’s the other person. When I designed it, I tried to minimise the need for the other person to be technically skilled. She just needs to follow the archetype and create content.

For this website and some others, I got still to do an implementation of Netlify CMS. This will fulfil all that is needed, proving, that having Hugo’s website is not only for skilled people but also for ordinary creators, who don’t need to know what the HTML even is.

1 Like

Excellent. Thanks.

Looking into one of the pages, i see the <picture> tag and the 4 sources along with the fallback img. I understand what the sources are doing (options for the best image to display along with offering the webp format) but seeing the resize values in each webp format, i’m wondering why you have the width and height value so large in the img tag, even so much larger than largest source webp option. What’s going on there and what’s the appropriate way to do that… I’ve been looking into the picture tag and was wondering what you put in the width and height of the image tag since that’s what “reserves” the space on render so you don’t have content jumping around.

On other sites, I got a different approach but on this, I made it even more differently.

For example, the featured image on a recipe will follow this

{{ if .Params.featuredImage }}

  {{ $ftimgsrc := resources.Get .Params.featuredImage }}

  {{ $tinyw := default "414x webp" }}
  {{ $smallw := default "839x webp" }}
  {{ $mediumw := default "1440x webp" }}
  {{ $largew := default "2048x webp" }}

  {{ $data := newScratch }}
  {{ $data.Set "tiny" ($ftimgsrc.Resize $tinyw) }}
  {{ $data.Set "small" ($ftimgsrc.Resize $smallw) }}
  {{ $data.Set "medium" ($ftimgsrc.Resize $mediumw) }}
  {{ $data.Set "large" ($ftimgsrc.Resize $largew) }}

  {{ $tiny := $data.Get "tiny" }}
  {{ $small := $data.Get "small" }}
  {{ $medium := $data.Get "medium" }}
  {{ $large := $data.Get "large" }}

<div class="featured-media">
  <picture>
    
    <source media="(max-width: 414px)" 
        srcset="{{with $tiny.RelPermalink }}{{.}}{{ end }}" type="image/webp">

    <source media="(max-width: 839px)" 
        srcset="{{with $small.RelPermalink }}{{.}}{{ end }}" type="image/webp">

    <source media="(max-width: 1440px)" 
        srcset="{{with $medium.RelPermalink }}{{.}}{{ end }}" type="image/webp">

    <source media="(min-width: 1441px)" 
        srcset="{{with $large.RelPermalink }}{{.}}{{ end }}" type="image/webp">
    
   <img
    src="{{ $ftimgsrc.Permalink | safeURL }}"
    alt="{{ .Title }}" title="{{ .Title }}"
    decoding="async"
    width="{{ $ftimgsrc.Width }}"
    height="{{ $ftimgsrc.Height }}">

  </picture>
</div>
{{ end }}

Where different size images will be displayed depending on user screen size.
For example, the source JPG is typically 2048px whereas on a typical mobile device it will display WEBP in max 414px width saving traffic and improving site performance.

You really adjusting it to your needs.
Hugo is generating WEBP during a build.

On my personal website, however, I like to generate WEBP on my own and I am serving it always the same resolution as the original. It’s working for me but this site, as mentioned, used a different approach.

When you will be building sites, try a different approach on a different one and see how it’s working on one and how on another to find the best solution. For me building another website like this help me improve others that I maintain.

I couldn’t serve full-size images on the homepage (for example) as this will have a negative impact on performance, so I used this for it:

{{ if .Params.featuredImage }}

  {{ $ftimgsrc := resources.Get .Params.featuredImage }}

  {{ $thumbnailw := default "800x webp" }}

  {{ $data := newScratch }}
  {{ $data.Set "thumbnail" ($ftimgsrc.Resize $thumbnailw) }}

  {{ $thumbnail := $data.Get "thumbnail" }}

<div class="featured-media">
  <picture>

    <source srcset="{{with $thumbnail.RelPermalink }}{{.}}{{ end }}" type="image/webp">
    
   <img
    src="{{ $ftimgsrc.Permalink | safeURL }}"
    alt="{{ .Title }}" title="{{ .Title }}"
    decoding="async"
    width="{{ $ftimgsrc.Width }}"
    height="{{ $ftimgsrc.Height }}">

  </picture>
</div>
{{ end }}

On the homepage, the typically featured image is 240px wide, however, including devices pixel density, I didn’t want to generate 3 images, (480 for 2x and 720 for 3x) hence using a compromise approach of 800px. The WEBP file at this resolution is not big and performance is not massively affected.

1st this is, you need to remember that the IMG tag needs to have a width and height set (for best practices). If WEBP is not used it’s always a fallback to full-size JPG. Of course, it will not display in full size on the website as CSS will do its magic, hence doesn’t matter if you specify width and height, as other bits and bats will do what is needed.

For example:

.single-recipe .featured-media img {
    height: auto;
    max-height: 80vh;
    object-fit:cover;
    width: 90%;
    border-radius: 5px;
}

Ideally would be, you could set width and height depending on user width, but that’s a dynamic approach so the site would need to be rendered individually. That’s outside of SSG.

As long CSS will follow with one AUTO element (height or width), and image aspect ratio will be maintained, all will scale nicely and despite if you state 1920x1080 but currently in the browser will be half of this size, the browser will scale it down (and display relevant image) without CLS issues (as you referred, “content jump”).

Thank you. So you put the original width and height in the img tag by default because

  1. it’s going to serve the img src at the file’s native size
  2. styling will control how it is really displayed

so it doesn’t really matter what width and height you use.

Yes, you are right.

Look at

 width="{{ $ftimgsrc.Width }}"
 height="{{ $ftimgsrc.Height }}">

Hugo is filling this during the build.
As long that width and height match with an aspect ratio of all images, all shall work without CLS.

If you put 1920x1080 but with CSS you ask to do 200x200 then you may experience CLS.

Nice website @idarek like it.

1 Like