Blog home

How To Build Crowdsourced Recipe App In 30 Minutes

Justin Hunter

Published on

15 min read

How To Build Crowdsourced Recipe App In 30 Minutes

The best recipes are the ones that come from friends and family. They are the ones typed out or handwritten that live on food-stained pieces of paper in some book on your pantry shelf. When we need a recipe not found in our homemade recipe book, we turn to the internet. And we are immediately hit with ads and walls of text before getting to the good stuff.

Let’s change that. Let’s build a crowdsourced recipe app that will act like our favorite family recipes. The app will be filterable and will have beautiful images. We’ll use Pinata to manage storage of the images and recipes. For our database, we’ll use Pinata’s Groups feature. For a more robust application, you may want to use a true database, but for this app, we’re going to keep it simple (KISS).

Getting Started

You will need the following to start on this app:

We will be using Next.js to build our app. Next allows us to easily handle client-side development and server-side development thanks to the serverless endpoints you can create. Let’s fire up the terminal and get started.

Run the following command:

npx create-next-app crowdsourced-recipes

You’ll be prompted to answer questions as your project is created. Answer the following:

  • Would you like to use TypeScript?
    • Yes, we love type safety here
  • Would you like to use ESLint?
    • No, we like to manually lint our code the same way we lint our dryer
  • Would you like to use Tailwind CSS?
    • Yes, because what is CSS if not Tailwind CSS these days, am I right?
  • Would you like to use src/ directory?
    • Yes, but I have nothing funny to add
  • Would you like to use App Router?
    • Yes, but this is where things get contentious. If you like Pages Router, use that and adjust. App Router is newer and technically recommended but Pages Router is still well-supported.
  • Would you like to customize the default import alias (@/*)?
    • No, “let it be” like Paul McCartney said.

When you’re done answering all of those questions, your project will be created. Now, change into the directory:

cd crowdsourced-recipes

Let’s get to coding!

Building the API

I like to start with the API first. Maybe it’s because I work for a developer tools company, or maybe it’s because I like APIs. Regardless, let’s build out our API then move on to the user interface. We will be making use of the Pinata Typescript SDK in our API, so let’s install it now.

npm i pinata-web3

Once you’ve done that, go ahead and log into your Pinata account and get yourself an API key. We’ll need the JWT from the API key generation process. You can see how to get that here. When you have your JWT, let’s add it to our project. In the root of your project, create a .env.local file. In that file, add the following:

PINATA_JWT=YOUR JWT HERE

With our Next.js project, the .gitignore file should automatically include all versions of .env.* but double check. You don’t want to commit secrets.

Now, go back to your Pinata account and go to the Gateways page. Copy the Gateway URL and let’s add that to our .env like this:

NEXT_PUBLIC_GATEWAY_URL=YOUR GATEWAY URL

Notice we’re using the NEXT_PUBLIC_ prefix? This is because we want to expose it to the browser. The gateway URL is not a secret, so this is safe.

With these variables, we can instantiate the Pinata SDK in our first API route. Let’s go ahead and build that route. This tutorial is using Next.js App Router. If you’re using Page Router instead, follow this guide and adjust your code to match. In the app directory, create a folder called api. Inside that folder, create another folder called recipes. Then, inside the recipes folder, add a file called route.tsx.

Now, inside your route.tsx file, add the following:

import pinata from "@/app/pinata";
import { NextResponse } from "next/server"

export async function GET(request: Request) {
  try {
    const {searchParams} = new URL(request.url);
    const category = searchParams.get("category");
    
    let files = []; 

    if(category && category !== 'undefined' && category !== "") {
      files = await pinata.listFiles().keyValue("recipes", "true").group(category).pageLimit(1000)
    } else {
      files = await pinata.listFiles().keyValue("recipes", "true").pageLimit(1000)
    }
    return NextResponse.json({ data: files }, { status: 200 })
  } catch (error) {
    console.log(error)
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
  }  
}

export async function POST(request: Request) {
  try {
    const body = await request.json()
    const { recipe, category, author, recipeName } = body;
    const group = await pinata.groups.create({
      name: category,
    });

    await pinata.upload.json(recipe).group(group.id)
    .addMetadata({
      name: recipeName,
      keyValues: {
        author: author, 
        recipes: "true"
      }
    })
    return NextResponse.json({ message: 'Success' }, { status: 200 })
  } catch (error) {
    console.log(error)
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
  }
}

This is our entire recipes API. How easy is that? The Pinata SDK makes uploads dead simple (we’ll make use of the SDK client-side as well to upload images soon). Our GET endpoint can take an optional query string parameter of category which will be used to fetch results from a Pinata group, if supplied. We also use the Pinata metadata keyvalue pair of "recipes": "true" to make sure we are only ever loading recipes. This is helpful when you want to load all recipes, not just the ones in a particular category.

Note: I’m not implementing pagination, but I am returning up to 1,000 recipes. If you want to implement pagination, the SDK has you covered.

The POST endpoint takes a JSON body that includes a JSON representation of the recipe, the author’s name, the recipe name, and the category (Pinata Group) to add the recipe to. We are using Pinata’s powerful metadata feature to associate some of those values as metadata rather than storing them as a JSON file. This will come in handy later.

This is a great start, but we still have a couple of other endpoints to create. In your api folder, add a new folder named categories. Inside that folder, add a file called route.tsx.

Inside that route.tsx file, add the following:

import { NextResponse } from "next/server"
import { PinataSDK } from "pinata-web3";

const pinata = new PinataSDK({
  pinataJwt: process.env.PINATA_JWT!,
  pinataGateway: process.env.NEXT_PUBLIC_GATEWAY_URL!,
});

export async function GET(request: Request) {
  try {
    const groups = await pinata.groups.list()
    return NextResponse.json({ data: groups }, { status: 200 })
  } catch (error) {
    console.log(error)
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
  }  
}

The first thing I notice is that we are re-instantiating the Pinata SDK. That’s not super efficient, so we’ll want to clean that up. But first, let’s explore this API route. Like our recipes route, this one is incredibly simple. We can create categories using Pinata’s Groups feature and we can retrieve categories.

Ok, let’s clean up these two endpoints before we move on and centralize our SDK. In the app folder, create a file called pinata.ts. In that file, add the SDK instantiation code:

import { PinataSDK } from "pinata-web3";

const pinata = new PinataSDK({
  pinataJwt: process.env.PINATA_JWT!,
  pinataGateway: process.env.NEXT_PUBLIC_GATEWAY_URL!,
});

export default pinata;

Then, in your API routes, remove the SDK instantiation and just import the pinata.ts file, like this:

import pinata from "@/app/pinata"

Ok, now we need one more endpoint. The last API endpoint we need to create is an auth endpoint. Why? Because in a production application where you want to use the client (browser) to upload to Pinata, you don’t want to expose your main API key. Instead, you’ll want to generate a limited-use key that each user can use to upload their recipes images.

We’re not going to implement a login system for our app, but we’ll mock the functionality in the API so that it’s easy to add later. For example, you might have an auth token from your auth service that you can send to the API endpoint, validate, and then proceed with generating a limited-use Pinata token.

In the api folder, create a new folder called auth. Inside the auth folder, add a file called route.tsx and populate it with the following:

import pinata from "@/app/pinata"
import { NextResponse } from "next/server"

export const dynamic = 'force-static'

export async function GET(request: Request) {
  try {
    //  Get the request's authorization token and verify it. 
    //  If the token is valid, then proceed.
    const key = await pinata.keys.create({
      keyName: Date.now().toString(),
      maxUses: 1,
      permissions: {
        admin: false,
        endpoints: {
          data: {
            pinList: true            
          },
          pinning: {
            hashMetadata: true,
            pinByHash: true,
            pinFileToIPFS: true,
            pinJSONToIPFS: true
          }
        }
      }
    });
    return NextResponse.json({ data: key.JWT }, { status: 200 })
  } catch (error) {
    console.log(error)
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
  }
}

This endpoint has a bit more code but that’s because we are setting granular permissions on the API key that we generate. We’re only allowing this key to be used once and only for certain endpoints that are necessary for the app.

Notice, at the top of the file, we have this line:

export const dynamic = 'force-static'

This ensures that Next.js doesn’t cache the API key response. We want a new key each time this endpoint is called.

Ok, that’s it for the endpoints. Let’s move on to the user interface.

Building The Interface

We’re going to keep our app as simple as possible, so that everything will happen on a single route. We’ll use components to dynamically render various content. Let’s get started. Find the page.tsx file at the root of the app folder. Update it to look like this:

'use-client'
import { useState } from "react";

export default function Home() {
  const [selectedCategory, setSelectedCategory] = useState("")

  return (
    <div className="min-w-screen min-h-screen">
      <div className="w-3/4 m-auto">
        <div className="flex justify-between">
          <h1 className="font-extrabold text-4xl">Recipes</h1>
          <button className="bg-purple p-4 rounded-full text-white">
            <svg xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
              <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
            </svg>
          </button>
        </div>
        <Categories selectedCategory={selectedCategory} setSelectedCategory={setSelectedCategory} />
        <Recipes selectedCategory={selectedCategory} />
      </div>
    </div>
  );
}

This is our very simple entry page. We will add a function to create new recipes and wire it up to the button we created soon. This page shows all of the categories of recipes and the current list of recipes. We’re passing the state variables into the necessary components to help us with logic and rendering.

We have not yet created either of the components, so let’s do that now. We’ll start with the Categories component. Inside the app folder, create a new folder called components. Inside that folder, create a file called Categories.tsx. And in your Categories.tsx file, add this:

import { GroupResponseItem } from 'pinata-web3';
import React, { useEffect, useState } from 'react'

type CategoriesProps = {
  selectedCategory: GroupResponseItem | null;
  setSelectedCategory: (category: GroupResponseItem) => void
}

const Categories = (props: CategoriesProps) => {
  const [categories, setCategories] = useState<GroupResponseItem[]>([])
  useEffect(() => {
    loadCategories()
  }, [])

  const loadCategories = async (category?: string) => {
    const res = await fetch(`/api/categories?category=${category}`)
    const categories = await res.json()
    console.log(categories)
    setCategories(categories.data)
  }

  return (
    <div>
      <div className="overflow-x-auto">
        <div className="flex space-x-4">
        {categories.length > 0 && categories.map((category: GroupResponseItem) => (
          <button onClick={() => props.setSelectedCategory(category)} key={category.id} className="whitespace-nowrap bg-gray-200 text-gray-800 px-4 py-2 rounded-md">
            {category.name}
          </button>
        ))}
      </div>
    </div>
    </div>
  )
}

export default Categories

This file is simple. We load the available categories by hitting our recipes API route. We display them on the screen horizontally with overflow scroll. And, when someone selects a category, we update the selectedCategory state value.

Notice we are using the Pinata SDK’s built-in type for the Group response to set the type for each category. This works well since we’re using Groups as our Category tool.

Now, we need to create the Recipies.tsx file. In the app folder, create that file. Inside it, add:

import { GroupResponseItem, PinListItem } from 'pinata-web3'
import React, { useEffect, useState } from 'react'
import pinata from '../pinata'

type RecipesProps = {
  selectedCategory: GroupResponseItem | null
}

type DataProps = {
  image: string;
  recipe: string;
}

const Recipes = (props: RecipesProps) => {
  const [recipes, setRecipes] = useState<PinListItem[]>([])
  const [see, setSee] = useState<PinListItem | null>(null)

  useEffect(() => {
    loadRecipes(props.selectedCategory)
  }, [props.selectedCategory]);

  const loadRecipes = async (category?: GroupResponseItem | null) => {
    const res = await fetch(`/api/recipes?category=${category?.id}`)
    const data = await res.json()
    const recipeData = data.data;
    for(const recipe of recipeData) {
      recipe.data = await loadData(recipe.ipfs_pin_hash)
    }
    setRecipes(recipeData)
  }

  const loadData = async (cid: string) => {
    //  load the main CID
    const file = await pinata.gateways.get(cid)
    return file.data
  }
  return (
    <div className="w-1/2 m-auto mt-10">
      {
        recipes.length > 0 ?
          recipes.map((r: any) => {            
            return (
              <div className="w-full border border-black rounded-md shadow" key={r.id}>
                <img className="w-full" src={`https://${process.env.NEXT_PUBLIC_GATEWAY_URL}/ipfs/${r?.data?.image}`} />
                <div className="p-6">
                  <h3>{r.metadata.name}</h3>
                  <p>{r?.metadata?.keyvalues?.author}</p>
                  <p>{new Date(r.date_pinned).toLocaleDateString()}</p>
                  <button onClick={see && see.id === r.id ? () => setSee(null) : () => setSee(r)}>{see && see.id === r.id ? "Hide recipe" : "See recipe"}</button>
                  {
                    see && see.id === r.id &&
                    <div>
                      {r.data?.recipe}
                    </div>
                  }
                </div>
              </div>
            )
          }) :
          <div className="mt-10 w-full m-auto flex flex-col items-center">
            <p className="text-center">No recipes yet, why don't you change that?</p>
            <svg xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-20 w-20 mt-6">
              <path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
            </svg>
          </div>
      }
    </div>
  )
}

export default Recipes

In this file, we are listening for changes to the selectedCategory then loading recipes accordingly. If there is no category selected, we will load all recipes. Since each recipe is a reference to a JSON file, we are using metadata associated with each reference to populate some of the recipe info (name, author, etc). Then, we are getting the actual JSON file’s content identifier and loading it so that we can return the recipe image and the recipe itself.

Right now, we don’t have any recipes. Let’s change that!

Back in your pages.tsx file at the root of the app folder, let’s update the file to add a new state variable and conditional logic to display the NewRecipeForm like this:

'use client'
import { useState } from "react";
import Categories from "./components/Categories";
import Recipes from "./components/Recipes";
import { GroupResponseItem } from "pinata";
import NewRecipeForm from "./components/NewRecipeForm";

export default function Home() {
  const [selectedCategory, setSelectedCategory] = useState<GroupResponseItem | null>(null)
  const [newRecipe, setNewRecipe] = useState(false)

  return (
    <div className="min-w-screen min-h-screen">
      <div className="w-3/4 m-auto">
        <div className="flex justify-between py-4">          
          <h1 className="font-extrabold text-4xl">Recipes</h1>
          <button onClick={() => setNewRecipe(true)} className="bg-purple-700 p-4 rounded-full text-white">
            <svg xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
              <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
            </svg>
          </button>
        </div>
        {
          newRecipe ? 
          <NewRecipeForm setNewRecipe={setNewRecipe} /> : 
          <div>
            <Categories selectedCategory={selectedCategory} setSelectedCategory={setSelectedCategory} />
            <Recipes selectedCategory={selectedCategory} />
          </div>
        }
      </div>
    </div>
  );
}

We are using the button we created earlier to trigger the change to the NewRecipeForm component. We haven’t built that component yet, so let’s do it.

In the components folder, create a new file called NewRecipeForm.tsx and add the following:

import React, { useState, useRef } from 'react';
import { PinataSDK } from "pinata-web3";

const pinata = new PinataSDK({
  pinataJwt: "dfdfgdf",
  pinataGateway: process.env.NEXT_PUBLIC_GATEWAY_URL!,
});

type NewRecipeFormProps = {
  setNewRecipe: (option: boolean) => void;
};

const NewRecipeForm = (props: NewRecipeFormProps) => {
  const [recipeName, setRecipeName] = useState('');
  const [authorName, setAuthorName] = useState('');
  const [category, setCategory] = useState('');
  const [recipe, setRecipe] = useState('');
  const [recipeImage, setRecipeImage] = useState<any>(null);

  const fileInputRef = useRef<HTMLInputElement>(null);

  const clear = () => {
    setRecipeName('');
    setAuthorName('');
    setCategory('');
    setRecipe('');
    setRecipeImage('');
  };

  const cancel = () => {
    clear();
    props.setNewRecipe(false);
  };

  const handleImageSelect = () => {
    if (fileInputRef.current) {
      fileInputRef.current.click();
    }
  };

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files[0]) {
      setRecipeImage(e.target.files[0]);
    }
  };

  const save = async (e: any) => {
    e.preventDefault()
  }

  return (
    <div className="mt-20">
      <h1 className="text-3xl font-bold">New Recipe</h1>
      <form onSubmit={save}>
        <div className="mt-6 flex items-center">
          <label className="w-40" htmlFor="recipeName">Recipe Name</label>
          <input
            className="py-2 px-4 border border-white rounded-md text-white bg-black w-full"
            type="text"
            id="recipeName"
            value={recipeName}
            placeholder="Southwestern Enchiladas"
            onChange={(e) => setRecipeName(e.target.value)}
          />
        </div>
        <div className="mt-6 flex items-center">
          <label className="w-40" htmlFor="authorName">Author Name</label>
          <input
            className="py-2 px-4 border border-white rounded-md text-white bg-black w-full"
            type="text"
            id="authorName"
            value={authorName}
            placeholder="Sandra Parker"
            onChange={(e) => setAuthorName(e.target.value)}
          />
        </div>
        <div className="mt-6 flex items-center">
          <label className="w-40" htmlFor="category">Category</label>
          <input
            className="py-2 px-4 border border-white rounded-md text-white bg-black w-full"
            type="text"
            id="category"
            value={category}
            placeholder="Hispanic Foods"
            onChange={(e) => setCategory(e.target.value)}
          />
        </div>
        <div className="mt-6 flex items-center">
          <label className="w-40" htmlFor="recipeImage">Recipe Image</label>
          <input
            ref={fileInputRef}
            type="file"
            id="recipeImage"
            className="hidden"
            onChange={handleFileChange}
          />
          <button
            className="bg-purple-600 p-4 rounded-sm text-white ml-4"
            onClick={handleImageSelect}
            type="button"
          >
            Upload Image
          </button>
        </div>
        <div className="mt-6 flex items-center">
          <label className="w-40" htmlFor="recipe">Recipe</label>
          <textarea
            className="rounded border p-4 bg-black text-white outline-none w-full h-32"
            id="recipe"
            value={recipe}
            placeholder="Enter the recipe details here..."
            onChange={(e) => setRecipe(e.target.value)}
          ></textarea>
        </div>
        <div className="mt-20">
          <div className="flex justify-end">
            <button onClick={cancel}>Cancel</button>
            <button type="submit" className="bg-purple-600 p-4 rounded-sm text-white ml-4">Save</button>
          </div>
        </div>
      </form>
    </div>
  );
};

export default NewRecipeForm;

We’re using a simple textarea for the recipe, but most people will want to have rich formatting. An improvement, in the future, might be to include a library like TipTap to provide more formatting options, but we’re building this fast, so textarea it is. The rest of the file is pretty straightforward. We’re creating a form that includes the recipe name, author name, the category, and, of course, the recipe. On cancel, we clear those fields and return back to the home view. On save, we have a function to handle that, but we need to wire it up. Let’s do that now.

In the placeholder save function from above, add the following:

const save = async () => {
    e.preventDefault()
    try {
      //  get one time token
      //  remember in a production app to add some authentication
      const res = await fetch("/api/auth")
      const tokenData = await res.json()
      const token = tokenData.data;  
      //  Upload image
      const data = await pinata.upload.file(recipeImage).key(token)

      //  Create category if it doesn't exist
      await fetch("/api/categories", {
        method: "POST",
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          name: category
        })
      })

      //  Upload the recipe
      const recipeObj = {
        recipe: {
          recipe: recipe,
          image: data.IpfsHash
        },
        category,
        author: authorName,
        recipeName: recipeName
      }

      await fetch("/api/recipes", {
        method: "POST",
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(recipeObj)
      })
      cancel()
    } catch (error) {
      console.log(error)
      alert("Trouble saving recipe, please try again")
    }
}

We first have to get a one-time token so that we can use it to upload our image. Our image might be small enough for our serverless function to handle on its own, but some images are large and you might find yourself with a request payload that’s too large for the serverless function. This method ensures you will never have to worry about that. We’re uploading from the client.

The Pinata SDK allows us to use a different token than the one the SDK is instantiated with. You can see that with the key property chained onto the function. Once we’ve uploaded the image, we can create the category if it doesn’t exist, then we can upload the recipe itself.

Because we built our APIs in advance, this is much easier. We know exactly what to send to the API. Once the uploads are done, we clear the fields and close the new recipe view.

We should be about done. Let’s run the app and take a look.

CleanShot 2024-08-20 at 11.21.15@2x.png

When you create a new category, it’ll show up at the top and can be clicked to filter by recipes in that category. Each recipe has basic details and an image. There is an expandable button to then read the entire recipe. So, it’s working well!

Next Steps

This is a very basic app. There’s a lot you can to do make it yours and build a crowdsourcing powerhouse. For example, here are some things you can consider:

  • Add user authentication
  • Use Pinata’s image optimization to reduce the size of the images to something that fits the screen better and loads faster
  • Add a delete function
  • Add comments or upvotes

No matter what you do, hopefully you can see how easy it is to build an app around file uploads and JSON data. Start building your own app by signing up for Pinata today.

You can access the repo for this project here. Happy Building!

Stay up to date

Join our newsletter for the latest stories & product updates from the Pinata community.

No spam, notifications only about new products, updates and freebies. You can always unsubscribe.