Back to blog
How To Build a Simple Podcast Hosting App
Ever find yourself in “Founder Mode” but lack the platform to tell people about it? Look no further! Behold: FounderCloud.live, a podcast platform for founders that need to yap. Whats under the hood? The Pinata Files API, enabling seamless uploads into your app experience. In this post we’ll show you how to use it to build a podcast hosting app that allows users to create podcasts and upload episodes to it. Not only that, but we’ll be pairing Pinata with Next, Supabase, and shadcn/ui for a really clean and polished app. Let’s get started!
Setup
There’s a few moving pieces here so let’s go over all the things you’ll need before we start building the app itself.
Pinata
First thing you’ll want to do is create a free Pinata account. Once inside the Pinata App select “API Keys” from the sidebar, then click “New Key” in the top right. We would recommend starting with Admin privileges and unlimited uses to start. You will receive a pinata_api_key
, pinata_api_secret
, and a JWT
. The JWT is the most common authentication method and what we’ll be using for this tutorial.
Next you will want to grab your Gateway domain by clicking the Gateways tab in the sidebar. You should see it listed in the format fun-llama-300.mypinata.cloud
and you will want to copy it exactly like that. Save all those details and we’ll use them again soon.
Supabase
To handle the records of our podcasts and episodes we’re going to use Supabase to spin up a quick database. To do that create a free account and then make a new project. With that project you should get a Project URL
and an API Key
that you will want to save somewhere safe.
Now to create our tables, you can simple copy the code below and past it into the SQL Editor located in the top left.
create table
public.podcasts (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
name text null,
group_id text null,
description text null,
image_url text null
) tablespace pg_default;
create table
public.episodes (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
podcast bigint null references public.podcasts (id),
audio_url text null,
description text null,
name text null
) tablespace pg_default;
This is a pretty simple database setup where we have a primary podcasts
table that has columns like name
, description
, image_url
for the cover, and group_id
for our uploads piece. The secondary table is episodes
and its very similar, only this time uses an audio_url
for the content and has a foreign key to link the episode to a specific podcast.
Next App
With all of that taken care of we can start moving it into our app. To do this you’ll need a little experience with Next.js or React frameworks in general, Node installed on your computer, and a good text editor. When you’re ready run the following command in your terminal and select all the defaults:
npx create-next-app@latest founder-cloud
Once its been initialized you will want to run the next command to cd
into the repo and install some dependencies.
cd founder-cloud && npm i pinata uuid @supabase/supabase-js
Now like we said earlier we’re going to use shadcn/ui for our components; they look great and save us a bunch of time from doing our own styles. Installation is simple:
npx shadcn@latest init
Once that has finished running we will run the next command to add our components.
npx shadcn@latest add button card dialog form input
This will put all of our UI components into a folder under components/ui
. Since they’re only installed incrementally you can edit the components in that folder to meet whatever style you want down the road!
It’s time to get our API keys and URLs setup, and we’ll do that by creating a new file called .env.local
in the root of our project with the following variables:
PINATA_JWT= # the Pinata JWT API key from earlier
NEXT_PUBLIC_GATEWAY_URL= # the Pinata Gateway URL from your acccount, in the format <my-domain>.mypinata.cloud
SUPABASE_URL= # your Supabase Project URL
SUPABASE_KEY= # your Supabase API Key
With those values in place we can add some little utility files to create instances we can use throughout our app. To start make a new file under the lib
folder called pinata.ts
and put in the following code:
import { PinataSDK } from "pinata";
export const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT!,
pinataGateway: process.env.NEXT_PUBLIC_GATEWAY_URL,
});
This tiny little constant will be our ticket to easy uploads as you’ll experience soon! If you ever need the docs to see what else it can do be sure to check them out here.
Another piece we’ll need is access to our database. In the same lib
folder make a new file called db.ts
with this piece of code.
import { createClient } from "@supabase/supabase-js";
// Create a single supabase client for interacting with your database
export const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_KEY!,
);
Normally these keys could be public, but since we are not implementing auth or RLS (row level security) in this tutorial we’ll just keep all our database interactions server side and treat them as secret.
With all of that setup we can finally start building our podcast app!
Building the Podcasts
In order for our app to work there are a few things it will need to do:
- Create podcasts
- List podcasts
- List episodes within a podcast
- Create episodes within a podcast
- List episodes within a podcast
Since our app needs are nested we’ll make the structure fairly nested as well. When building with Next App Router it helps to visualize those needs and structures and identify them as server or client components. For our app it might look something like this:
- Home page (server)
- Lists podcasts
- Button to create a podcast (client)
- Podcast page (server)
- Show podcast info
- List episodes (server)
- Button to add episode (client)
- List episodes (server)
- Show podcast info
With all of that in mind lets build our server rendered home page under app/page.tsx
. Remove all the boilerplate and put this code in.
import { CreatePodcastForm } from "@/components/create-podcast-form";
import { supabase } from "@/lib/db";
import { Card } from "@/components/ui/card";
import Link from "next/link";
export const revalidate = 60;
export type Podcast = {
id: number;
created_at: string;
name: string;
group_id: string;
description: string;
image_url: string;
};
async function fetchData(): Promise<Podcast[]> {
try {
const supabase = createClient();
const { data, error } = await supabase.from("podcasts").select("*");
return data as Podcast[];
} catch (error) {
console.log(error);
return [];
}
}
export default async function Home() {
const data = await fetchData();
console.log(data);
return (
<main className="flex min-h-screen flex-col items-center justify-start p-24 gap-12">
<div className="flex flex-col gap-2 text-center">
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
FounderCloud
</h1>
<h4 className="scroll-m-20 text-xl font-semibold tracking-tight">
Yap like you have nothing else to do
</h4>
</div>
{data.map((item: Podcast) => (
<Card className="overflow-hidden max-w-[400px]" key={item.id}>
<Link href={`/podcast/${item.id}`}>
<img
src={item.image_url}
className="max-h-[400px] max-w-[400px] object-cover"
alt={item.id.toString()}
/>
<div className="p-2 flex flex-col gap-2">
<h3 className="croll-m-20 text-2xl font-semibold tracking-tight">
{item.name}
</h3>
<p>{item.description}</p>
</div>
</Link>
</Card>
))}
</main>
);
}
Let’s go over what’s happening in this code. First we import all of our components and utilities, then we declare some types as well as revalidate
export to make sure we see new podcasts as they’re created. Then we declare a fetchData
function which fetches all the podcasts from our database table podcasts
. The beauty here is since all components and pages are by default SSR we don’t have to use anything like useEffect
or useState
to fetch everything; It just loads all of it when the page loads. To take advantage of that we just call the fetchData
function inside our Home
page, then map over it and make little card components.
When you do this you’ll notice there’s no podcasts; duh! We need to make some! To give users the ability to create a podcast we’ll need two pieces. First let’s make a new API route by creating a folder called api
, then podcast
, then a route.ts
file inside of that. Over all the path should be app/api/podcast/route.ts
. Inside that file we’ll put the following code.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { pinata } from "@/lib/pinata";
import { supabase } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const name = formData.get("name");
const description = formData.get("description");
const file: File | null = formData.get("image") as unknown as File;
const group = await pinata.groups.create({
name: name as string,
isPublic: true,
});
const { cid } = await pinata.upload.file(file).group(group.id);
const imageUrl = `https://${process.env.NEXT_PUBLIC_GATEWAY_URL}/files/${cid}`;
const { data, error } = await supabase
.from("podcasts")
.insert({
name: name,
group_id: group.id,
description: description,
image_url: imageUrl,
})
.select();
console.log(data);
if (error) {
console.log(error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
return NextResponse.json(data, { status: 200 });
} catch (e) {
console.log(e);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
This API route will essentially create a podcast in our database. In order to create a podcast we need a title, description, and cover image. Let’s hone in on the cover image a bit: all files on the new Files API by Pinata will be private by default. Since this is content we don’t mind being public, we need to first created a group and make it public. The SDK makes this a breeze with just a few lines of code, where we make a group using the name of the podcast and then marking isPublic
as true
. With the group created we can then upload our file into that group in just one line of code! This will return a cid
which acts as a unique identifier and hash of our file. We’ll use it to form an image URL that we store in the database. Then its just a matter of inserting all the data as a row into our podcasts
table and sending back the response; nice!
All of this is great, but in order to get the user input and make an API call, we need to make one more component. Make a new file called create-podcast-form.tsx
in the components
folder and put in the following code:
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogTitle,
} from "@/components/ui/dialog";
import { ReloadIcon } from "@radix-ui/react-icons";
const formSchema = z.object({
name: z.string().min(2),
description: z.string().min(2),
file: z.any(),
});
export function CreatePodcastForm() {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
},
});
const fileRef = form.register("file");
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
const file: File | null = values.file ? values.file[0] : null;
if (file) {
const data = new FormData();
data.append("name", values.name);
data.append("description", values.description);
data.append("image", file);
const createPodcastRequest = await fetch("/api/podcast", {
method: "POST",
body: data,
});
const createPodcast = await createPodcastRequest.json();
console.log(createPodcast);
setIsLoading(false);
} else {
console.log("no file selected");
setIsLoading(false);
}
}
function ButtonLoading() {
return (
<Button disabled>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Please wait
</Button>
);
}
return (
<Dialog>
<DialogTrigger>
<Button>Create Podcast</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Create a Podcast</DialogTitle>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Founder Mode" {...field} />
</FormControl>
<FormDescription>
What's the name of your Podcast?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="Just go founder mode" {...field} />
</FormControl>
<FormDescription>What is your podcast about?</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>Cover Image</FormLabel>
<FormControl>
<Input type="file" {...fileRef} />
</FormControl>
<FormDescription>
Select a cover image for your podcast
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isLoading ? (
ButtonLoading()
) : (
<Button type="submit">Submit</Button>
)}
</form>
</Form>
</DialogContent>
</Dialog>
);
}
There’s a lot of code here, but the actual logic is pretty simple. First we make this a client side component with "use client"
at the top of the file so we can have things like buttons and inputs. We import a bunch of UI components to build a form for people to fill out as well as a dialog. This works like a modal, so when the user clicks the Create Podcast
button inside the <DialogTrigger>
it will open the <DialogContent>
with our form to fill out. Really smooth!
With that form we have to put together a schema which can help us validate the intake of our form, then we have the almighty onSubmit
function. This takes all of our form inputs and attached them to an API request we make to the endpoint we just made: /api/podcast
.
Let’s go back into our app/page.tsx
file and add this component in right below the podcast list:
import { CreatePodcastForm } from "@/components/create-podcast-form";
// rest of imports
// rest of the code
export default async function Home() {
const data = await fetchData();
console.log(data);
return (
<main className="flex min-h-screen flex-col items-center justify-start p-24 gap-12">
<div className="flex flex-col gap-2 text-center">
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
FounderCloud
</h1>
<h4 className="scroll-m-20 text-xl font-semibold tracking-tight">
Yap like you have nothing else to do
</h4>
</div>
{data.map((item: Podcast) => (
<Card className="overflow-hidden max-w-[400px]" key={item.id}>
<Link href={`/podcast/${item.id}`}>
<img
src={item.image_url}
className="max-h-[400px] max-w-[400px] object-cover"
alt={item.id.toString()}
/>
<div className="p-2 flex flex-col gap-2">
<h3 className="croll-m-20 text-2xl font-semibold tracking-tight">
{item.name}
</h3>
<p>{item.description}</p>
</div>
</Link>
</Card>
))}
<CreatePodcastForm />
</main>
);
}
Now you can give it a shot; try making a podcast! Keep in mind that the name of the podcast has to be unique, as the Groups feature of the Files API requires a unique name for your account.
We have our podcast, but we need a page for each podcast to list and add episodes to it. To do that we’ll use a dynamic route in Next. Start by making a folder inside the app
folder called podcast
and in there we’ll make one more folder called [id]
. Finally inside of that folder make a page.tsx
file, so our full path looks like app/podcast/[id]/page.tsx
. With this structure we can pass an argument in the path like http://localhost:3000/podcast/1
to pull information for podcast id 1
. Inside our page.tsx
file let’s put in the following code:
import { EpisodeList } from "@/components/episode-list";
import { supabase } from "@/lib/db";
export type Podcast = {
id: number;
created_at: string;
name: string;
group_id: string;
description: string;
image_url: string;
};
async function fetchData(id: string): Promise<Podcast[]> {
try {
const { data, error } = await supabase
.from("podcasts")
.select("*")
.eq("id", id);
return data as Podcast[];
} catch (error) {
console.log(error);
return [];
}
}
export default async function Page({ params }: { params: { id: string } }) {
const data = await fetchData(params.id);
console.log(data);
const show = data[0];
return (
<main className="flex min-h-screen flex-col items-center justify-start p-24 gap-12">
<div className="flex flex-col items-start gap-12">
<div className="overflow-hidden" key={show.id}>
<img
src={show.image_url}
className="max-h-[400px] max-w-[400px] object-cover"
alt={show.id.toString()}
/>
<div className="p-2 flex flex-col gap-2">
<h3 className="croll-m-20 text-2xl font-semibold tracking-tight">
{show.name}
</h3>
<p>{show.description}</p>
</div>
</div>
</div>
</main>
);
}
You’ll notice this looks pretty similar to our original page but with just a few small tweaks. To start we have the path param that we discussed earlier and we use that as an argument in our fetchData
function. With this setup we’ll only select the podcast that matches the id
passed in the path, then display it’s data.
Building the Episodes
We’ve got everything we need now to actually list and create episodes for each podcast. To start this up we’ll make a new file called episode-list.tsx
inside the components
folder and put the following content inside.
import { supabase } from "@/lib/db";
import { CreateEpisodeForm } from "./create-episode-form";
import { Button } from "./ui/button";
import Link from "next/link";
export const revalidate = 60;
export type Episode = {
id: number;
created_at: string;
podcast: string;
audio_url: string;
description: string;
name: string;
};
async function fetchData(id: string): Promise<Episode[]> {
try {
const { data, error } = await supabase
.from("episodes")
.select("*")
.eq("podcast", id);
return data as Episode[];
} catch (error) {
console.log(error);
return [];
}
}
export async function EpisodeList({
id,
groupId
}: { id: string; groupId: string; }) {
const data = await fetchData(id);
console.log(data);
return (
<div className="flex flex-col gap-12 w-full">
{data.map((item: Episode) => (
<div key={item.id} className="flex flex-col gap-2">
<h4 className="scroll-m-20 text-xl font-semibold tracking-tight">
{item.name}
</h4>
<p>{item.description}</p>
<audio controls src={item.audio_url}>
<track kind="captions" src="" label="English" />
</audio>
</div>
))}
<div className="flex justify-center gap-4">
<Button asChild>
<Link href="/"> Go Back </Link>
</Button>
<CreateEpisodeForm
id={id}
groupId={groupId}
/>
</div>
</div>
);
}
This component is also pretty simple and works similar to our other server rendered pages, where we have some parameters and fetch the episodes from our database. In particular we only pull episodes from the podcast we’re looking at, aka the id
. We also take in a groupId
that we’ll need for putting episodes into, which we’ll see soon. The episode data includes things like the linked podcast, the name and description of the episode, and of course the audio for it. For our app we’re just doing audio but since the Files API allows any kind of file you could also do video here too! Once we fetch that data from the database we just map it into the JSX.
You’ll notice from the code above that we have another component we haven’t made yet; let’s do that now! Inside the componenets
folder make a new file called create-episode-form.tsx
and put in the following code.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ReloadIcon } from "@radix-ui/react-icons";
import { pinata } from "@/lib/pinata";
const formSchema = z.object({
name: z.string().min(2),
description: z.string().min(2),
file: z.any(),
});
export function CreateEpisodeForm({
groupId,
id,
}: { groupId: string; id: string; }) {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
},
});
const fileRef = form.register("file");
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
const file: File | null = values.file ? values.file[0] : null;
if (file) {
const keyRequest = await fetch("/api/key");
const keys = await keyRequest.json();
const uploadFile = await pinata.upload
.file(file)
.key(keys.JWT)
.group(groupId);
const data = new FormData();
data.append("name", values.name);
data.append("description", values.description);
data.append(
"audio",
`https://${process.env.NEXT_PUBLIC_GATEWAY_URL}/files/${uploadFile.cid}`,
);
data.append("podcastId", id);
const createEpisodeRequest = await fetch("/api/episode", {
method: "POST",
body: data,
});
const createEpisode = await createEpisodeRequest.json();
console.log(createEpisode);
setIsLoading(false);
} else {
console.log("no file selected");
setIsLoading(false);
}
}
function ButtonLoading() {
return (
<Button disabled>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Please wait
</Button>
);
}
return (
<Dialog>
<DialogTrigger>
<Button>Add Episode</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Add an Episode</DialogTitle>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Once upon a time" {...field} />
</FormControl>
<FormDescription>
What's the title of the episode
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="On this week's episode..." {...field} />
</FormControl>
<FormDescription>
What happens in this episode?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>Audio</FormLabel>
<FormControl>
<Input type="file" {...fileRef} />
</FormControl>
<FormDescription>
Select the Audio file for the Podcast
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isLoading ? (
ButtonLoading()
) : (
<Button type="submit">Submit</Button>
)}
</form>
</Form>
</DialogContent>
</Dialog>
);
}
If you take a look you’ll see it looks very similar to the other form we created; in fact it’s almost identical except for one very important piece. When it comes to doing uploads in Next.js there is a size limit of what can be passed through our API route, 4mb to be precise. While that’s fine for the cover image we did earlier, its not ideal for a larger audio or video file that we need for the episode. Since this is a client side component we risk exposing our API key if we try to use our pinata
instance here to upload it. Thankfully the Pinata SDK has a trick up it’s sleeve 😏
Right before we start the API call to make an episode, we have a request to /api/key
; let’s make that API path right now and make a route.ts
file inside with this code:
import { type NextRequest, NextResponse } from "next/server";
const { v4: uuidv4 } = require("uuid");
import { pinata } from "@/lib/pinata";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest, res: NextResponse) {
try {
const uuid = uuidv4();
const keyData = await pinata.keys.create({
keyName: uuid.toString(),
permissions: {
endpoints: {
pinning: {
pinFileToIPFS: true,
},
},
},
maxUses: 1,
});
return NextResponse.json(keyData, { status: 200 });
} catch (error) {
console.log(error);
return NextResponse.json(
{ text: "Error creating API Key:" },
{ status: 500 },
);
}
}
This little handler here will make a limited scope/use Pinata JWT. It can only do a single file upload, which is all we need! The SDK makes it easy to create these keys and scope them as necessary. We just make the key and send it back to the client.
Now you may be saying “but Steve, how do we use an API key if we already passed it into our instance?” I’m glad you asked! (and only slightly offended if you didn’t 😉) The SDK has a special method on uploads that allows you to pass in a temporary API key! So if we look closer at our upload flow it looks something like this:
// rest of onSubmit()
const keyRequest = await fetch("/api/key");
const keys = await keyRequest.json();
const uploadFile = await pinata.upload
.file(file)
.key(keys.JWT)
.group(groupId);
// rest of function
With just a few lines of code we essentially created a signed JWT that we can use for our larger file upload on the client, bypassing the restrictive file size of the Next API routes! The SDK shines at being flexible for whatever situations you might find yourself in. We also used that groupId
that was passed into the component, making sure our audio files stay in the same group as our podcast. Now if we continue the onSubmit
function you’ll notice something else.
// rest of onSubmit()
const data = new FormData();
data.append("name", values.name);
data.append("description", values.description);
data.append(
"audio",
`https://${process.env.NEXT_PUBLIC_GATEWAY_URL}/files/${uploadFile.cid}`,
);
data.append("podcastId", id);
const createEpisodeRequest = await fetch("/api/episode", {
method: "POST",
body: data,
});
const createEpisode = await createEpisodeRequest.json();
console.log(createEpisode);
setIsLoading(false);
You’ll see here we attach all of our properties like we did earlier with the podcast form such as the name and description. We also form our audio_url
just like we did on the /api/podcast
route but just client side this time. From there we append our podcastId
and then make another API call to /api/episode
; you know the drill, let’s make app/api/episode/route.ts
and put this code in:
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { supabase } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const name = formData.get("name");
const description = formData.get("description");
const podcastId = formData.get("podcastId");
const audioUrl = formData.get("audio");
const { data, error } = await supabase
.from("episodes")
.insert({
name: name,
description: description,
audio_url: audioUrl,
podcast: podcastId,
})
.select();
console.log(data);
if (error) {
console.log(error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
return NextResponse.json(data, { status: 200 });
} catch (e) {
console.log(e);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
Even simpler than the last one; we just take all our form items and push them as a new row in our episodes
table. Before you even realized it, we’re done! We have a fully functioning podcast hosting app, targeted at Founders who really need to yap.
Wrapping Up
The truth is this is just the beginning of where you can take this. Ideally you would want to setup something like auth to make sure the right people are uploading to the right podcast. Luckily for you we did that in our final repo which you can check out here. If this is what you can do with public files, imagine what you can do with private files! Pinata makes it so easy to upload files in your app and access them with public groups like we did here, or with signed URLs that expire after a period of time:
import { PinataSDK } from "pinata";
const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT!,
pinataGateway: "example-gateway.mypinata.cloud",
});
const url = await pinata.gateways.createSignedURL({
cid: "bafkreib4pqtikzdjlj4zigobmd63lig7u6oxlug24snlr6atjlmlza45dq",
expires: 2400,
});
We’re excited about the possibilities this new Files API brings and we’ll be sharing them along the way, so be sure to subscribe below!