Last updated on

Adding External data in Astro


I am working on a longer post about how I have started using Mealie to organize my recipes and manage my grocery list. In the middle of that, I decided I wanted to have an easy way to share recipes from Mealie. I currently block external traffic to Mealie, so the “share” feature that is built into it does not work for me. I could open it to public traffic, but I am working toward a zero trust policy on my network so that would go against that philosophy.

I built this site using Astro so I had flexibility, and I realized I could fetch recipe data from the Mealie API. So, what if I used Astro to fetch the data and used that to generate my recipe content? Turns out you can, I wired things up, and I can now easily choose recipes from Mealie I want to share, add a bit of context, and when my site deploys it includes the recipe details. The nice thing is that if I tweak a recipe, those tweaks show up the next time I publish content, without any effort on my part.

You can use this same method to fetch data from pretty much any source to add external data to a static site built with Astro.

Here is how I integrated Mealie into my Astro site.

First, I created a content collection. Add this to src/content/config.ts

Technically you could use the external data directly, but having a collection gives more control than just fetching.


const recipes = defineCollection({
	type: 'content',
	schema: ({image}) => z.object(
		// Using this so that I do not need to build fetching of images into the process. It is an extra manual step, but then we can take advantage of Asto's image handling. 
		heroImage: image().refine((img) => img.width >= 1080, {
			message: "Cover image must be at least 1080 pixels wide!",
		  }).optional(),
		// This is the slug for the recipe in Mealie
		recipe_slug: z.string(),
		pubDate: z.coerce.date(),
		is_draft: z.boolean().optional().default(false)
	}),
})

In this code the heroImage is an image I store in the content directory of my site. and recipe_slug refers to the slug of the recipe in my Mealie site.

Next, you will want to create two new pages src/pages/recipes.[slug].astro and src/pages/recipes/index.astro. You can skip the index page if you do not want a listing page.

In [slug].astro, you need to export a getStaticPaths function that will first fetch the ContentCollection, then fetch the recipes from Mealie and augment the data. If you are only using the external data, skip the getCollection call and just fetch your data.


export async function getStaticPaths() {
	// fetch all the recipes async. If the number of recipes grows we may need to rate limit this.
	const merged = await Promise.all((await getCollection('recipes')).map(async (recipe) => {
		// we just brute force the data together, but you could cherry-pick your data. I also ensured recipe is last so I could override values from Mealie if wanted.
	const from_mealie = await fetchRecipe(recipe.data.recipe_slug);
		return Object.assign({}, from_mealie, recipe)
	}))
	// sort by the blog publish date
	const recipes = merged.sort(
		(b, a) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf()
	).filter((recipe) => {
		return recipe.data.is_draft !== true
	});
	// this is tells Astro how to build the paths for the pages. 
	return recipes.map( (recipe) => ({
		params: { slug: recipe.data.recipe_slug },
		props: recipe,
	}));
	
	async function fetchRecipe(slug: string) {
		const response = await fetch(`https://yourmealieurl/api/recipes/${slug}`, {
		method: 'GET',
		headers: {
			'Authorization': `Bearer ${import.meta.env.PUBLIC_MEALIE_API_KEY} `
		}
	})

		const recipe = await response.json();
		return recipe
    }
}

This code gets all the .md files under /src/content/recipes, then for each recipe we fetch the data from Mealie with the slug. Once fetched we merge the data from the content collection with the data from the Mealie API. Then we pass the merged object to a Recipe Component.


type Props = RecipeType;

const recipe = Astro.props;
const { Content } = await recipe.render();
---

<Recipe {...recipe}>
	<Content />
</Recipe>

And my Recipe component.


---
import BaseHead from '../components/BaseHead.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import FormattedDate from '../components/FormattedDate.astro';
import Ingredient from '../components/Ingredient.astro';
import { SITE_TITLE } from '../consts';
import Instruction from '../components/Instruction.astro';
import { Image } from 'astro:assets';
import type RecipeType from '../lib/content/recipes'

type Props = RecipeType;
const { data, name, updateAt, description, recipeYield, recipeIngredient, recipeInstructions} = Astro.props;
---

<html lang="en">
	<head>
		<BaseHead title={`${name} | ${SITE_TITLE}`} description={description} />
		<link rel="author" href="https://connermccall.com" />
		</style>
	</head>

	<body>
		<Header />
		<main class="full h-entry">
			<article>
				
				<div class="flex hero-image pb-4 justify-center">
					{data.heroImage && <Image src={data.heroImage} alt="" />}
				</div>
				<div>
					<div class="title max-w-prose mx-auto">
						<div class="date text-center text-lg">
							<FormattedDate date={data.pubDate} />
							{
								updateAt && (
									<div class="last-updated-on dt-published">
										Last updated on <FormattedDate date={new Date(updateAt)} />
									</div>
								)
							}
						</div>
						<h1 class="center py-4 text-center p-name"><a class="u-url text-black hover:text-black" href={`/blog/${Astro.props.slug}/`}>{name}</a></h1>
						<hr class="pb-4"/>
					</div>
					<div class="prose mx-auto e-content">
						<slot />
						<h2>Description</h2>
                        <p>{description}</p>
                        <h2>Makes</h2>
						<p>{recipeYield}</p>
                        <div>
                            <h2>Ingredients</h2>
                            <ul class=" bg-gray-100 py-3  ">
                                
                            {
                                recipeIngredient.map((ingredient) => (
                                <li><Ingredient ingredient={ingredient} /></li>
                                ))
                            }
                            </ul>
                        </div>
                        <div>
                            <h2>Instructions</h2>
                                {
                                    recipeInstructions.map((instruction) => (
                                    <Instruction instruction={instruction} />
                                    ))
                                }
                                
                        </div>
				    </div>
				</div>
			</article>
		</main>
		<Footer />
	</body>
</html>

You do almost the same thing in index.astro. Fetch the data, merge the properties, then you can just loop through the recipe and show the details you want on your landing page.


const merged = await Promise.all((await getCollection('recipes')).map(async (recipe) => {
	const from_mealie = await fetchRecipe(recipe.data.recipe_slug);
	return Object.assign({}, from_mealie, recipe.data)
}))
const recipes = merged.sort(
	(b, a) => a.pubDate.valueOf() - b.pubDate.valueOf()
).filter((recipe) => {
	return recipe.is_draft !== true
});
...
{
	recipes.map((data) => (
		
	<li class=" max-w-[444px] md:max-w-none">
			<a href={`/recipes/${data.slug}/`}>
				<Image alt="" height="300" src={data.heroImage} />
				<h4 class="title text-xl pt-4">{data.name}</h4>
				<p class="date text-sm text-gray-600">
					<FormattedDate date={data.pubDate} />
				</p>
			</a>
		</li>
	
	))
}

I explored an alternative approach where I tagged recipes in Mealie with a ‘blog’ tag and then queried for all recipes with that tag. Doing this I could avoid the need to use a Content Collection and only use data from the api. I decided that using a Content Collection gave me the benefit of ensuring that this codebase controlled what recipes appeared, I could add additional context to the recipe that I do not need in Mealie, and I can control the image show alongside the recipe while taking advantage of Astro’s image processing.

Look for my post about Mealie in a few days.

You can see the code in context in my demo (github repository)[https://github.com/sloped/astro-site].

How to reply to this post