Nextjs Pagination and Search with Create and Upload Image | Postgresql Prisma
Next.js
Install requirements
Zod
Zod is a TypeScript-first schema declaration and validation library.
https://www.npmjs.com/package/zod
Prisma is an open-source next-generation ORM. It consists of the following parts: Prisma Client: Auto-generated and type-safe query builder
https://www.prisma.io
Install prisma/client
npm install @prisma/client
https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/generating-prisma-client
Generate Prisma Client with the following command:
npx prisma generate
Install prisma
npm install prisma --save-dev
https://www.prisma.io/docs/getting-started/quickstart
set up Prisma with the init command of the Prisma CLI:
npx prisma init --datasource-provider sqlite
to npx prisma init --datasource-provider postgres
Model data in the Prisma schema
prisma/schema.prisma
model Post {
id String @id @default(cuid())
title String
image String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run a migration to create your database tables with Prisma Migrate
npx prisma migrate dev --name init
.env
DATABASE_URL="postgresql://postgres:admin@localhost:5432/postgresDB?schema=public"
https://www.prisma.io/docs/orm/prisma-client/queries/crud
https://nextjs.org/docs/app/building-your-application/routing/route-handlers
app\post\page.tsx
//app\post\page.tsx
import PostTable from "@/components/table";
import { getDataPages } from "@/lib/data";
import Pagination from "@/components/pagination";
import Link from "next/link";
import { Suspense } from "react";
import { Spinner } from "@/components/spinner";
import Search from "@/components/search";
const Posts = async ({
searchParams,
}: {
searchParams?: {
query?: string;
page?: string;
};
}) => {
const query = searchParams?.query || "";
const currentPage = Number(searchParams?.page) || 1;
const totalPages = await getDataPages(query);
console.log(searchParams);
console.log(query);
console.log(currentPage);
return (
<div className="max-w-screen-lg mx-auto py-14">
<h1 className="text-4xl font-bold">Nextjs Pagination and Search with Create and Upload Image | Postgresql Prisma</h1>
<Link
href="/create"
className="py-3 px-6 bg-green-700 hover:bg-green-800 text-white"
>
New Post
</Link>
<div className="flex items-end justify-between m-12">
<h1 className="text-4xl font-bold">Latest Post</h1>
<div><Search /></div>
</div>
<Suspense key={query + currentPage} fallback={<Spinner />}>
<PostTable query={query} currentPage={currentPage} />
</Suspense>
<div className="flex justify-center mt-4">
<Pagination totalPages={totalPages} />
</div>
</div>
);
};
export default Posts;
components\table.tsx
//components\table.tsx
import Image from "next/image";
import { formatDate } from "@/lib/utils";
import { getPosts } from "@/lib/data";
const PostTable = async ({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) => {
const posts = await getPosts(query, currentPage);
return (
<div className="grid md:grid-cols-3 gap-5 mt-10">
{posts.map((item) => (
<div key={item.id} className="max-w-sm border border-gray-200 rounded-md shadow">
<div className="relative aspect-video">
<Image
src={`http://localhost:3000/assets/${item.image}`}
alt={item.title}
fill
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="rounded-t-md object-cover"
/>
</div>
<div className="p-5">
<h1 className="text-2xl font-bold text-gray-900 truncate">
{item.title}
</h1>
</div>
<div className="flex items-center justify-between">
<div className="py-3 text-sm text-white bg-blue-700 rounded-bl-md w-full text-center">{formatDate(item.createdAt.toString())}</div>
</div>
</div>
))}
</div>
);
};
export default PostTable;
components\button.tsx
//components\button.tsx
"use client";
import { useFormStatus } from "react-dom";
import { clsx } from "clsx";
export const SubmitButton = ({ label }: { label: string }) => {
const { pending } = useFormStatus();
return (
<button
className={clsx(
"bg-blue-700 text-white w-full font-medium py-2.5 px-6 text-base rounded-sm hover:bg-blue-600",
{
"opacity-50 cursor-progress": pending,
}
)}
type="submit"
disabled={pending}
>
{label === "upload" ? (
<>{pending ? "Uploading..." : "Upload"}</>
) : (
<>{pending ? "Updating..." : "Update"}</>
)}
</button>
);
};
components\upload-form.tsx
//components\upload-form.tsx
"use client";
import React from "react";
import { CreateData } from "@/lib/actions";
import { useFormState } from "react-dom";
import { SubmitButton } from "@/components/button";
const UploadForm = () => {
const [state, formAction] = useFormState(CreateData, null);
return (
<form action={formAction}>
{/* Alert */}
{state?.message ? (
<div
className="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50"
role="alert"
>
<div className="font-medium">{state?.message}</div>
</div>
) : null}
<div className="mb-4 pt-2">
<input
type="text"
name="title"
className="py-2 px-4 rounded-sm border border-gray-400 w-full"
placeholder="Title..."
/>
<div aria-live="polite" aria-atomic="true">
<p className="text-sm text-red-500 mt-2">{state?.error?.title}</p>
</div>
</div>
<div className="mb-4 pt-2">
<input
type="file"
name="image"
className="file:py-2 file:px-4 file:mr-4 file:rounded-sm file:border-0 file:bg-gray-200 hover:file:bg-gray-300 file:cursor-pointer border border-gray-400 w-full"
/>
<div aria-live="polite" aria-atomic="true">
<p className="text-sm text-red-500 mt-2">{state?.error?.image}</p>
</div>
</div>
<div className="mb-4 pt-4">
<SubmitButton label="upload" />
</div>
</form>
);
};
export default UploadForm;
components\search.tsx
//components\search.tsx
"use client";
import { IoSearch } from "react-icons/io5";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useDebouncedCallback } from "use-debounce"; //npm i use-debounce --save https://www.npmjs.com/package/use-debounce
const Search = () => {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const handleSearch = useDebouncedCallback((term: string) => {
// console.log(term);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}, 300);
return (
<div className="relative flex flex-1">
<input
type="text"
className="w-full border border-gray-200 py-2 pl-10 text-sm outline-2 rounded-sm"
placeholder="Search..."
onChange={(e) => handleSearch(e.target.value)}
defaultValue={searchParams.get("query")?.toString()}
/>
<IoSearch className="absolute left-3 top-2 h-5 w-5 text-gray-500" />
</div>
);
};
export default Search;
components\Spinner.tsx
//components\Spinner.tsx
export const Spinner = () => {
return (
<span className="loading loading-spinner loading-lg">Loading</span>
);
};
components\pagination.tsx
//components\pagination.tsx
"use client";
import Link from "next/link";
import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; //npm install react-icons --save https://www.npmjs.com/package/react-icons
import { usePathname, useSearchParams } from "next/navigation";
import { generatePagination } from "@/lib/utils";
import clsx from "clsx";
const Pagination = ({ totalPages }: { totalPages: number }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get("page")) || 1;
const createPageURL = (pageNumber: string | number) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
const allPages = generatePagination(currentPage, totalPages);
const PaginationNumber = ({
page,
href,
position,
isActive,
}: {
page: number | string;
href: string;
position?: "first" | "last" | "middle" | "single";
isActive: boolean;
}) => {
const className = clsx(
"flex h-10 w-10 items-center justify-center text-sm border",
{
"rounded-l-sm": position === "first" || position === "single",
"rounded-r-sm": position === "last" || position === "single",
"z-10 bg-blue-100 border-blue-500 text-white": isActive,
"hover:bg-gray-100": !isActive && position !== "middle",
"text-gray-300 pointer-events-none": position === "middle",
}
);
return isActive && position === "middle" ? (
<div className={className}>{page}</div>
) : (
<Link href={href} className={className}>
{page}
</Link>
);
};
const PaginationArrow = ({
href,
direction,
isDisabled,
}: {
href: string;
direction: "left" | "right";
isDisabled?: boolean;
}) => {
const className = clsx(
"flex h-10 w-10 items-center justify-center text-sm border",
{
"pointer-events-none text-gray-300": isDisabled,
"hover:bg-gray-100": !isDisabled,
"mr-2": direction === "left",
"ml-2": direction === "right",
}
);
const icon =
direction === "left" ? (
<HiChevronLeft size={20} />
) : (
<HiChevronRight size={20} />
);
return isDisabled ? (
<div className={className}>{icon}</div>
) : (
<Link href={href} className={className}>
{icon}
</Link>
);
};
return (
<div className="inline-flex">
<PaginationArrow
direction="left"
href={createPageURL(currentPage - 1)}
isDisabled={currentPage <= 1}
/>
<div className="flex -space-x-px">
{allPages.map((page, index) => {
let position: "first" | "last" | "single" | "middle" | undefined;
if (index === 0) position = "first";
if (index === allPages.length - 1) position = "last";
if (allPages.length === 1) position = "single";
if (page === "...") position = "middle";
return (
<PaginationNumber
key={index}
href={createPageURL(page)}
page={page}
position={position}
isActive={currentPage === page}
/>
);
})}
</div>
<PaginationArrow
direction="right"
href={createPageURL(currentPage + 1)}
isDisabled={currentPage >= totalPages}
/>
</div>
);
};
export default Pagination;
app\create\page.tsx
//app\create\page.tsx
import UploadForm from "@/components/upload-form";
const CreatePage = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100">
<div className="bg-white rounded-sm shadow p-8">
<h1 className="text-2xl font-bold mb-5">New Post</h1>
<UploadForm />
</div>
</div>
);
};
export default CreatePage;
lib\actions.ts
//lib\actions.ts
"use server";
import { z } from "zod"; //https://www.npmjs.com/package/zod
import { prisma } from "@/lib/prisma";
import { writeFile } from "fs/promises";
import path from "path";
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
const UploadSchema = z.object({
title: z.string().min(1),
image: z
.instanceof(File)
.refine((file) => file.size > 0, { message: "Image is required" })
.refine((file) => file.size === 0 || file.type.startsWith("image/"), {
message: "Only images are allowed",
})
.refine((file) => file.size < 4000000, {
message: "Image must less than 4MB",
}),
});
export const CreateData = async (prevState: unknown, formData: FormData) => {
const validatedFields = UploadSchema.safeParse(
Object.fromEntries(formData.entries())
);
if (!validatedFields.success) {
return {
error: validatedFields.error.flatten().fieldErrors,
};
}
const file = formData.get("image");
const { title } = validatedFields.data;
try {
const buffer = Buffer.from(await file.arrayBuffer());
const filename = file.name.replaceAll(" ", "_");
console.log(filename);
await writeFile(
path.join(process.cwd(), "public/assets/" + filename),
buffer
);
await prisma.post.create({
data: {
title,
image: filename,
},
});
//return { message: "Success" };
} catch (error) {
console.log("Error occured ", error);
return { message: "Failed" };
}
revalidatePath("/post");
redirect("/post");
};
lib\data.ts
//lib\data.ts
import { prisma } from "@/lib/prisma";
const ITEMS_PER_PAGE = 3;
export const getPosts = async (query: string, currentPage: number) => {
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
try {
const posts = await prisma.post.findMany({
skip: offset,
take: ITEMS_PER_PAGE,
where: {
OR: [
{
title: {
contains: query,
mode: "insensitive",
},
},
],
},
orderBy: { createdAt: "desc" },
});
return posts;
} catch (error) {
throw new Error("Failed to fetch contact data");
}
};
export const getDataPages = async (query: string) => {
try {
const posts = await prisma.post.count({
where: {
OR: [
{
title: {
contains: query,
mode: "insensitive",
},
},
],
},
orderBy: { createdAt: "desc" },
});
const totalPages = Math.ceil(Number(posts) / ITEMS_PER_PAGE);
return totalPages;
} catch (error) {
throw new Error("Failed to fetch contact data");
}
};
lib\prisma.ts
//lib\prisma.ts
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
export const prisma = globalThis.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;
lib\utils.ts
//lib\utils.ts
export const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const formatter = new Intl.DateTimeFormat("id-ID", {
dateStyle: "medium",
});
return formatter.format(date);
};
export const generatePagination = (currentPage: number, totalPages: number) => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
if (currentPage <= 3) {
return [1, 2, 3, "...", totalPages - 1, totalPages];
}
if (currentPage >= totalPages - 2) {
return [1, 2, 3, "...", totalPages - 2, totalPages - 1, totalPages];
}
return [
1,
"...",
currentPage - 1,
currentPage,
currentPage + 1,
"...",
totalPages,
];
};
prisma\schema.prisma
//prisma\schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id String @id @default(cuid())
title String
image String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
.env
DATABASE_URL="postgresql://postgres:admin@localhost:5432/postgresDB?schema=public"
run C:\nextjs>npm run dev
VIDEO