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 annpx
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
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: Tip: Set the authorized URLs to Add the env variables: Follow the setup to create the route handler: We will set up the login links later.Kinde Auth Setup
$ pnpm add @kinde-oss/kinde-auth-nextjs
http://localhost:8888
as this is the port Netlify dev uses by default.
// src/app/api/auth/[kindeAuth]/route.ts
import { handleAuth } from '@kinde-oss/kinde-auth-nextjs/server';
export const GET = handleAuth();
Once you've created a project, select Prisma from the dashboard and keep the database URL ready for the next step.Neon DB Setup
Go to https://neon.tech/ and sign up for a free Neon DB.
Add Prisma dev dependency: Initialize prisma: 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 Prisma Setup
$ pnpm add -D prisma
$ pnpm dlx prisma init
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!
Top comments (4)
good job!
Thanks!
Nice explanation!
Thanks!