How to Build a Simple Instagram Clone with Next.js and Netlify

How to Build a Simple Instagram Clone with Next.js and Netlify

Hi! I spent most of my weekend trying to build a simple image-sharing social media web app for the Netlify Dev challenge. In this post, I will explain what I have learned while making this project. Most of this article is geared towards beginners, but I still hope everyone can find this useful, thanks!

Code: https://github.com/ridays2001/mini-gallery
Preview: https://mini-gallery.netlify.app/

The Tech Stack

Next.js: My favorite framework. I used the Next.js App directory with React Server Components (RSCs).

ShadCN UI: An excellent UI library builder. I used the CLI tool to get the components that I needed. It is built on Radix UI and Tailwind CSS.

Postgres (With Neon DB): It’s been a while since I had last used Postgres. I also wanted to give Neon a try. I had heard good things about their serverless Postgres.

Prisma: An ORM to make working with relational databases easier. Prisma gives us type safety while working with db queries. It also makes our lives a whole lot easier.

Kinde Auth: I have seen a lot of ads for Kinde Auth speed run. So, I always wanted to give it a try. It gives fine defaults and makes handling auth very easy and quick.

Netlify: The star of the show.

The ideology behind selecting this tech stack was that I wanted to try out things I had never used before and learn them (except Next.js – which I use all the time).

Initial Setup

The first step is to use the create-next-app to get started.

$ pnpm dlx create-next-app@latest

P. S. – I have configured pnx=”pnpm dlx” alias so get an npx like feel.

Next, install ShadCN/UI. Use the CLI tool to get started quickly.

$ pnpm dlx shadcn-ui@latest init

Next, install the Netlify CLI and login to use the Netlify features like blobs while developing locally.

$ pnpm add netlify -g && netlify login

Kinde Auth Setup

Go to https://kinde.com/ and set up Auth. Sign up and follow the prompts. In the first step, I would recommend selecting a location near to where your server would be (Netlify functions location)

After this, select existing project > Next.js and enable whatever auth options you like:

I chose Email, Google, and GitHub. One thing I liked about Kinde is that you can add any OAuth you like from the list and they will add their own OAuth credentials so that you can get started quickly. This is okay for a simple hobby project where you don’t have to worry about 100 different verification requirements. For a real project, please complete the platform verification and use your own credentials.

Now, integrate this into the codebase. Start by installing their SDK:

$ pnpm add @kinde-oss/kinde-auth-nextjs

Tip: Set the authorized URLs to http://localhost:8888 as this is the port Netlify dev uses by default.

Add the env variables:

Follow the setup to create the route handler:

// src/app/api/auth/[kindeAuth]/route.ts

import { handleAuth } from @kinde-oss/kinde-auth-nextjs/server;

export const GET = handleAuth();

We will set up the login links later.

Neon DB Setup

Go to https://neon.tech/ and sign up for a free Neon DB.

Once you’ve created a project, select Prisma from the dashboard and keep the database URL ready for the next step.

Prisma Setup

Ref: https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases-typescript-postgresql

Add Prisma dev dependency:

$ pnpm add -D prisma

Initialize prisma:

$ pnpm dlx prisma init

Add the database URL you got from the Neon DB setup in the previous setup to the .env file.

Add a top-level property to your package.json file for Prisma configuration. This allows us to place the Prisma schema file in a different folder.

{
“name”: “mini-gallery”

“prisma”: {
“schema”: “src/prisma/schema.prisma”
},

}

Add a Prisma schema:

// src/prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
provider = prisma-client-js
}

datasource db {
provider = postgresql
url = env(DATABASE_URL)
}

model User {
id String @id
name String
username String? @unique
bio String?
picture String?
createdAt DateTime @default(now())
posts Post[]
Comment Comment[]
}

model Post {
id String @id
title String
likes Int @default(0)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
comments Comment[]
createdAt DateTime @default(now())
blurUrl String
width Int
height Int
}

model Comment {
id String @id
content String
createdAt DateTime @default(now())
Post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String?
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
}

Run this command to let Prisma do its magic:

$ pnpm dlx prisma migrate dev –name init

This command will create the tables required in the database, generate all the required type definitions, and add the Prisma client.

Tip: If you need to do any change in the schema without creating a new migration, you can use pnpm dlx prisma db push

Creating a User

The first step is for the user to sign up before creating any posts. We are using Kinde auth, so this step is easy. Use the components from their Next.js SDK:

// src/components/Header.tsx

import {
getKindeServerSession,
LoginLink,
LogoutLink,
RegisterLink
} from @kinde-oss/kinde-auth-nextjs/server;

// …

export async function Header() {
const { getUser, isAuthenticated } = getKindeServerSession();

const isLoggedIn = await isAuthenticated();
const user = await getUser();

return (
<header>
{/* … */}
{isLoggedIn ? (
<LogoutLink>Logout</LogoutLink>
) : (
<nav>
<LoginLink>Login</LoginLink>
<RegisterLink>Register</RegisterLink>
</nav>
)}
{/* … */}
</header>
);

After that, we make a helper function that will add the Kinde user to our database:

// src/lib/db.ts

import { getKindeServerSession } from @kinde-oss/kinde-auth-nextjs/server;
import { PrismaClient } from @prisma/client;
import { redirect } from next/navigation;
import { cache } from react;

function getPrismaClient() {
const prisma = new PrismaClient();
return prisma;
}

export const getPrisma = cache(getPrismaClient);

export async function getUser(required = false) {
const { getUser: getKindeUser } = getKindeServerSession();
const kindeUser = await getKindeUser();
if (required && !kindeUser) redirect(/api/auth/login);
if (!kindeUser) return null;

const prisma = getPrisma();
const user = await prisma.user.findUnique({
where: { id: kindeUser?.id },
include: { posts: true }
});
if (!user) {
await prisma.user.create({
data: {
id: kindeUser.id,
name: `${kindeUser.given_name} ${kindeUser.family_name}`,
picture: kindeUser.picture
}
});
}

return {
id: kindeUser.id,
name: user?.name ?? `${kindeUser.given_name} ${kindeUser.family_name}`,
picture: kindeUser.picture,
user
};
}

Now, we have basic auth and a user in our db. Now, they can create a post.

Creating a Post

Create a simple form that will accept the title and image for the post:

// src/app/posts/new/NewPostForm.tsx

use client;

import { Button } from @/components/ui/button;
import { Label } from @/components/ui/label;
import { useEffect } from react;
import { useFormState } from react-dom;
import { toast } from sonner;
import { createPostAction } from ./action;

function CreatePostForm() {
const [state, action] = useFormState(createPostAction, {});

useEffect(() => {
if (state.success) {
toast.success(state.message ?? Form submitted successfully!);
}
if (state.error) {
toast.error(state.message ?? Failed to submit form.);
}
}, [state]);

<form action={action}>
<Label htmlFor=‘title’>Title</Label>
<input name=‘title’ required />

<Label htmlFor=‘image’>Image</Label>
<input name=‘image’ type=‘file’ required />

<Button type=‘submit’>Create Post</Button>
</form>;
}

All the forms in my app are client components. Next.js allows us to mark some components as client components and server components. Server components are more performant, but they don’t have much interactivity since they are rendered on the server.

We use server actions to send data from the client to the server.

// src/app/posts/new/action.ts

use server;

import { getPrisma, getUser } from @/lib/db;
import { generateId } from @/lib/utils;
import { getStore } from @netlify/blobs;
import { revalidatePath } from next/cache;
import { redirect } from next/navigation;

import type { ServerActionState } from @/lib/types;

export async function createPostAction(
_prevState: ServerActionState,
formData: FormData
): Promise<ServerActionState> {
const data = {
title: formData.get(title) as string,
image: formData.get(image) as File,
};

const user = await getUser();
if (!user) {
return {
error: true,
message: You must be logged in to create a new post!
};
}

const store = getStore(posts);
const id = generateId();

// Save the image in the store.
await store.set(id, data.image, { metadata: { authorId: user.id } });

const prisma = getPrisma();
await prisma.post.create({
data: {
id,
title: data.title,
// …
author: {
connect: { id: user.id }
}
}
});

// Revalidate the paths to update the page content.
revalidatePath(/profile);
revalidatePath(/);

redirect(`/posts/${id}`);
}

We save the image in Netlify Blob storage against the post id with basic metadata like post authorId.

Serve the Image

Unfortunately, Netlify Blob storage does not provide us with a URL for the uploaded file. We need to handle the serving ourselves using an API route.

// src/app/posts/raw/[id]/route.ts

import { getStore } from @netlify/blobs;

export async function GET(_req: Request, { params: { id } }: GetRawPostProps) {
const store = getStore(posts);
const { data, metadata } = await store.getWithMetadata(id, {
type: blob
});
if (!data) return new Response(Not found, { status: 404 });

return new Response(data, {
headers: {
Netlify-CDN-Cache-Control:
public, s-maxage=31536000, must-revalidate,
Netlify-Cache-Tag: [id, metadata.authorId ?? ].join(,)
}
});
}

type GetRawPostProps = {
params: { id: string };
};

We cache it for a year in the Netlify CDN and assign some cache tags to purge the cache on-demand when the post or user is deleted.

Displaying the Posts

We can feed the raw post URL from the above route to the Next.js Image tag to display it.

// src/app/posts/[id]/page.tsx

import { getPrisma } from @/lib/db;
import Image from next/image;
import { notFound } from next/navigation;

export default async function PostPage({ params: { id } }: PostPageProps) {
const prisma = getPrisma();
const post = await prisma.post.findUnique({
where: { id },
include: { author: true, comments: { include: { author: true } } }
});

if (!post) notFound();

return (
<article className=‘max-w-prose mx-auto flex flex-col gap-6’>
{/* … */}
<Image
alt={post.title}
src={`/posts/raw/${post.id}`}
width={post.width}
height={post.height}
placeholder=‘blur’
blurDataURL={post.blurUrl}
className=’rounded-lg’
/>
{/* … */}
</article>
);
}

type PostPageProps = {
params: { id: string };
};

Since we are using Netlify’s Next.js runtime, it will automatically handle the image optimization provided by the next/image component.

Adding Likes

We use the useOptimistic and useTransition hooks to add likes to the post.

// src/app/posts/[id]/LikeButton.tsx

use client;

import { Button } from @/components/ui/button;
import { HeartIcon } from @radix-ui/react-icons;
import { useOptimistic, useTransition } from react;
import { likeAction } from ./actions;

export function LikeButton({ likes, postId }: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimisticLikes, addOptimisticLikes] = useOptimistic<number, void>(
likes,
currentLikes => currentLikes + 1
);

return (
<Button
variant=‘ghost’
className=‘h-auto p-2 gap-2 flex-wrap md:flex-nowrap’
onClick={async () => {
startTransition(async () => {
addOptimisticLikes();
await likeAction(postId);
});
}}
disabled={isPending}
>
{isPending && (
<span className=‘flex items-center’>
<span className=‘loader’ />
</span>
)}

<HeartIcon className=‘w-8 h-8 md:w-10 md:h-10 text-primary’ />

<span className=‘text-lg md:text-xl’>{optimisticLikes}</span>
</Button>
);
}

type LikeButtonProps = {
likes: number;
postId: string;
};

This also gives us a chance to show a loader or to just instantly increment the like count without waiting for the server.

// src/app/posts/[id]/actions.ts

use server;

// …

export async function likeAction(postId: string) {
const prisma = getPrisma();
await prisma.post.update({
where: { id: postId },
data: { likes: { increment: 1 } }
});

return { success: true, message: Post liked! };
}

// …

In case of an error, we can simply use revalidatePath(/posts/${postId}) to reset the like count back to normal. Since we are using optimistic updates, the like count is added before the server action is completed.

Deleting A Post

First, create a simple form to trigger the deletion:

use client;

import { Button } from @/components/ui/button;
import { useFormState } from react-dom;
import { deletePostAction } from ./actions;

export function DeletePostForm({ postId }: DeletePostFormProps) {
const [state, action] = useFormState(deletePostAction, {});

return (
<form action={action}>
<input type=‘hidden’ name=‘postId’ value={postId} />
<Button type=‘submit’>Delete Post</Button>
</form>
);
}

export type DeletePostFormProps = {
postId: string;
};

When the user clicks on the delete post button, the form is submitted along with the injected post ID.

// src/app/posts/[id]/actions.ts

use server;

import { getPrisma } from @/lib/db;
import { purgeCache } from @netlify/functions;
import { revalidatePath } from next/cache;
import { redirect } from next/navigation;

import type { ServerActionState } from @/lib/types;

export async function deletePostAction(
_prevState: ServerActionState,
formData: FormData
): Promise<ServerActionState> {
const data = {
postId: formData.get(postId) as string
};

const prisma = getPrisma();
await prisma.post.delete({ where: { id: data.postId } });

await purgeCache({
tags: [data.postId]
});

revalidatePath(/);
redirect(/);
}

In the server action, we delete the post image from the Netlify Blob storage first. Then, we also have to purge it from the Netlify cache (our cache rules are for a year). We can easily do this using the purgeCache function provided by @netlify/functions and supply the post ID as the tag to purge. We had set this tag while serving the raw post image.

Wrapping Up

This blog post just gives a simple overview of the entire process so that you can get started with your project. You can check out the complete code on my GitHub. Feel free to reach out if you have any questions.

Thanks for reading!

Leave a Reply

Your email address will not be published. Required fields are marked *