This is a tiny app built to demonstrate usage around API-interaction and UI design patterns in NextJS 13.
🌐 inventory-app-mauve.vercel.appBuilt with the following tech-stack:
- NextJS 13: Frontend UI
- Prisma: Database ORM
- TailwindCSS: CSS Styling
- ShadCN: UI Components
- AWS SDK: Image Upload to S3
NextJS 13, ShadCN and Prisma are having a dramatically positive impact on how modern web applications are built with speed. The idea behind this mini CRUD demo is to show the usage of the following patterns:
- Server Actions -> As API endpoints
- Action Response Class -> For unified API response behaviour
- Global Modal -> Unified modal behaviour
- Server-Side Search -> Persisting search state using search params
- Infinite Scroll -> Utilizing server actions and intersection observer API
- Bunch related actions together to find common patterns and abstractions.
- Revalidate path inside server action
- Instantiate action response class to unify responses
@/lib/actions.ts
export async function createProduct(data: ...) {
...
}
export async function getProducts() {
const products = await prisma.product.findMany({});
return products;
}
export async function updateProduct({id, data}) {
try {
const product = await prisma.product.update({
where: {
id,
},
data,
});
revalidatePath('/');
return ActionResponse.success('Product updated successfully', product);
} catch (error: any) {
return ActionResponse.error(
error.message || 'Product update failed',
error
);
}
}
- Define an action response class
type ActionResponseType = {
success: boolean;
message: string;
data?: any;
status?: number;
};
class ActionResponse {
success: boolean;
message: string;
data: any;
private constructor(success: boolean, message: string, data: any) {
this.success = success;
this.message = message;
this.data = data;
}
static success(message: string, data?: any): ActionResponseType {
return new ActionResponse(true, message, data);
}
static error(message: string, data?: any): ActionResponseType {
return new ActionResponse(false, message, data);
}
}
export default ActionResponse;
- Handle responses with a simple conditional clientside
...
const handleChange = async (checked: boolean) => {
const res = await updateIsDeal({ id, isDeal: !checked });
if (res.success) {
toast.success(res.message);
} else {
toast.error(res.message);
}
};
...
Use a context-based modal provider pattern to open modals from anywhere in the app.
- Create modal context
export function ModalProvider({ children }) {
const [isOpen, setIsOpen] = useState(false);
const [modalContent, setModalContent] = useState<React.ReactNode>(null);
const show = (content: React.ReactNode) => {
setModalContent(content);
setIsOpen(true);
};
...
const hide = () => {
setIsOpen(false);
setModalContent(false);
};
...
return (
<ModalContext.Provider
value={{
show,
hide,
isOpen,
}}
>
{children}
<AnimatePresence>
{isOpen && <ModalPortal>{modalContent}</ModalPortal>}
</AnimatePresence>
</ModalContext.Provider>
);
}
- Show bottomsheet on mobile and centered modal on desktop
export function ModalPortal({ children }: ModalPortalProps) {
...
return (
<>
<ModalBackground>
{isMobile && <MobileModal ...>{children}</MobileModal>}
{isDesktop && <DesktopModal>{children}</DesktopModal>}
</ModalBackground>
</>
);
}
- Call modal with custom content from anywhere
const Component = () => {
const { show } = useModal();
return <Button onClick={() => show(<ProductForm />)}>...</Button>;
};
export default Component;
- Replace router path with new values
- Debounce operation in case of frequent user input
- Keep logic within 1x component
const SearchInput = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams);
if (value === "" || value === undefined) {
params.delete(name);
return params.toString();
}
params.set(name, value);
return params.toString();
},
[searchParams]
);
const handleChange = useCallback(
debounce((e: React.ChangeEvent<HTMLInputElement>) => {
router.push(pathname + "?" + createQueryString("search", e.target.value));
}, 150),
[]
);
return (
<div>
<Input
placeholder="Search product..."
onChange={handleChange}
/>
</div>
);
};
export default SearchInput;
- Fetch new data server-side based on new URL
export default async function Home({ searchParams }: { searchParams: any }) {
const { search } = searchParams;
const products = await getProducts({ search });
return (
...
<div ...>
<SearchInput />
...
</div>
<ProductsTable products={products} key={Math.random()} />
...
);
}
- Initialize serverside products as clientside state
- Initialize page state
- Define "fetchMore" function
const PAGE_SIZE = 25;
const ProductsTable = ({ products }) => {
...
const [renderedProducts, setRenderedProducts] = useState(products);
const [isEnd, setIsEnd] = useState(false);
const [page, setPage] = useState(1);
const fetchMoreProducts = async () => {
const nextPage = page + 1;
const moreProducts = await getProducts({
search,
skip: page * PAGE_SIZE,
take: PAGE_SIZE,
});
setRenderedProducts([...renderedProducts, ...moreProducts]);
if (moreProducts.length < PAGE_SIZE) {
setIsEnd(true);
return;
}
setPage(nextPage);
};
...
return (
<div ...>
... {renderedProducts.map((product) => <div>...</div>)} ...
</div>
);
};
export default ProductsTable;
- Reference a loader component
- Fetch more products if component comes into view
const ProductsTable = ({ products }) => {
...
const { ref, inView, entry } = useInView();
useEffect(() => {
fetchMoreProducts();
}, [inView]);
return (
<div ...>
...
{!isEnd && (
<div ref={ref} className="flex w-full flex-col gap-4 p-4">
<Skeleton className="h-[80px] w-full rounded-md" />
<Skeleton className="h-[80px] w-full rounded-md" />
<Skeleton className="h-[80px] w-full rounded-md" />
</div>
)}
</div>
);
};
export default ProductsTable;
I hope you found some helpful shortcuts and abstractions above! Clone the repo, to find the full implementation and start the project locally yourself.