DEV Community

Riday
Riday

Posted on

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
Enter fullscreen mode Exit fullscreen mode

Create Next App

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
Enter fullscreen mode Exit fullscreen mode

ShadCN/UI Installation

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

$ pnpm add netlify -g && netlify login
Enter fullscreen mode Exit fullscreen mode

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

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

Kinde Auth Options

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
Enter fullscreen mode Exit fullscreen mode

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

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();
Enter fullscreen mode Exit fullscreen mode

We will set up the login links later.


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

Neon DB - Creating a Project

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

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
Enter fullscreen mode Exit fullscreen mode

Initialize prisma:

$ pnpm dlx prisma init
Enter fullscreen mode Exit fullscreen mode

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"
    },
    ...
}
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

Run this command to let Prisma do its magic:

$ pnpm dlx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

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>
    );
Enter fullscreen mode Exit fullscreen mode

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
    };
}

Enter fullscreen mode Exit fullscreen mode

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>;
}

Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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!' };
}

// ...
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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('/');
}
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (4)

Collapse
 
miguelrodriguezp99 profile image
Miguel

good job!

Collapse
 
ridays2001 profile image
Riday

Thanks!

Collapse
 
brogrammer11052001 profile image
Bhavesh

Nice explanation!

Collapse
 
ridays2001 profile image
Riday

Thanks!