How to Build a Simple Instagram Clone with Next.js and Netlify
Tags: nextjs, tutorial, netlify, opensource
Published: 2024-05-12 14:04:56

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](https://nextjs.org): My favorite framework. I used the Next.js App directory with React Server Components (RSCs).

-   [ShadCN UI](https://ui.shadcn.com): 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](https://neon.tech)): 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](https://prisma.io): 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](https://kinde.com): 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](https://netlify.com): 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.

```sh
$ pnpm dlx create-next-app@latest
```

![Create Next App](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y02pv6xmg0vgoqqthpeg.png)
> 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.

```sh
$ pnpm dlx shadcn-ui@latest init
```

![ShadCN/UI Installation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/55ltisf1dd7rjrqbi6hx.png)

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

```sh
$ pnpm add netlify -g && netlify login
```

{% details 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)

![Kinde Auth Registration](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u7188v3gfh75oyu60suz.png)

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

![Kinde Auth Options](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/za8lzw0ilb7iunc1pmt7.png)

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:

```sh
$ 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:

![Env variables](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x9r3oqv4wsmmh7tqg23s.png)

Follow the setup to create the route handler:

```ts
// 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.

{% enddetails %}

{% details Neon DB Setup %}
Go to https://neon.tech/ and sign up for a free Neon DB.

![Neon DB - Creating a Project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dxb1h6ztgw84onrqqxca.png)

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

![Neon DB - Select Prisma](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t0sf044zfoqr2ql6rf6m.png)

{% enddetails %}

{% details Prisma Setup %}

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

Add Prisma dev dependency:
```sh
$ pnpm add -D prisma
```

Initialize prisma:
```sh
$ pnpm dlx prisma init
```

![Prisma init](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jdkxk2vqv9t7rcr5jv0q.png)

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.

```json
{
    "name": "mini-gallery"
    ...
    "prisma": {
        "schema": "src/prisma/schema.prisma"
    },
    ...
}
```

{% enddetails %}

Add a Prisma schema:

```ts
// 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:
```sh
$ 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:

```tsx
// 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:

```ts
// 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:

```tsx
// 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.

```ts
// 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.

```ts
// 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.

```tsx
// 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.

```tsx
// 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.

```ts
// 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:
```tsx
'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.

```ts
// 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](https://github.com/ridays2001/mini-gallery). Feel free to reach out if you have any questions.

Thanks for reading!