├── services ├── guides.service.ts └── users.service.ts ├── .eslintrc.json ├── .env ├── app ├── favicon.ico ├── page.tsx ├── layout.tsx └── globals.css ├── next.config.mjs ├── types ├── user.types.ts └── axios.types.ts ├── postcss.config.mjs ├── actions └── user.actions.ts ├── lib ├── utils.ts └── axios.ts ├── components ├── ui │ ├── skeleton.tsx │ └── avatar.tsx ├── user-list.client.tsx ├── user-list-loading.tsx └── user-list.tsx ├── components.json ├── .gitignore ├── providers └── ReactQueryProvider.tsx ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── package.json ├── README.md ├── hooks └── useUser.ts └── tailwind.config.ts /services/guides.service.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=https://66b2046a1ca8ad33d4f62740.mockapi.io/api/v1 -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ugurkellecioglu/nextjs-service-layer-pattern/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /types/user.types.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | name: string 3 | avatar: string 4 | id: string 5 | createdAt: string 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /actions/user.actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import usersService from "@/services/users.service" 4 | 5 | export const getUsers = async () => { 6 | return usersService.getUsers() 7 | } 8 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /lib/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | const api = axios.create({ 4 | headers: { 5 | "Content-Type": "application/json", 6 | }, 7 | baseURL: process.env.NEXT_PUBLIC_API_URL, 8 | }) 9 | 10 | export { api } 11 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import UserListClient from "@/components/user-list.client" 2 | 3 | export default async function Home() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /types/axios.types.ts: -------------------------------------------------------------------------------- 1 | export type SuccessResponse = { 2 | success: true 3 | data: T 4 | } 5 | 6 | export type ErrorResponse = { 7 | success: false 8 | data: string 9 | } 10 | 11 | export type Response = SuccessResponse | ErrorResponse 12 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/user-list.client.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useGetUsers } from "@/hooks/useUser" 4 | import UserList from "./user-list" 5 | import UserListLoading from "./user-list-loading" 6 | 7 | export default function UserListClient() { 8 | const { data, status } = useGetUsers() 9 | 10 | if (status === "pending") return 11 | 12 | if (data?.success) { 13 | return 14 | } else if (!data?.success) { 15 | return
Error: {data?.data}
16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /providers/ReactQueryProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query" 4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools" 5 | import { useState } from "react" 6 | export default function ReactQueryProvider({ 7 | children, 8 | }: { 9 | children: React.ReactNode 10 | }) { 11 | const [client] = useState(new QueryClient()) 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import ReactQueryProvider from "@/providers/ReactQueryProvider" 2 | import type { Metadata } from "next" 3 | import { Inter } from "next/font/google" 4 | import "./globals.css" 5 | 6 | const inter = Inter({ subsets: ["latin"] }) 7 | 8 | export const metadata: Metadata = { 9 | title: "Create Next App", 10 | description: "Generated by create next app", 11 | } 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode 17 | }>) { 18 | return ( 19 | 20 | 21 | {children} 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "service-layer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-avatar": "^1.1.0", 13 | "@tanstack/react-query": "^5.51.21", 14 | "@tanstack/react-query-devtools": "^5.51.21", 15 | "axios": "^1.7.3", 16 | "class-variance-authority": "^0.7.0", 17 | "clsx": "^2.1.1", 18 | "lucide-react": "^0.424.0", 19 | "next": "14.2.5", 20 | "react": "^18", 21 | "react-dom": "^18", 22 | "tailwind-merge": "^2.4.0", 23 | "tailwindcss-animate": "^1.0.7" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^20", 27 | "@types/react": "^18", 28 | "@types/react-dom": "^18", 29 | "eslint": "^8", 30 | "eslint-config-next": "14.2.5", 31 | "postcss": "^8", 32 | "tailwindcss": "^3.4.1", 33 | "typescript": "^5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /services/users.service.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@/lib/axios" 2 | import { Response } from "@/types/axios.types" 3 | import { User } from "@/types/user.types" 4 | 5 | class UsersService { 6 | async getUsers(): Promise> { 7 | try { 8 | const res = await api.get("/users") 9 | return { 10 | success: true, 11 | data: res.data, 12 | } 13 | } catch (error: any) { 14 | return { 15 | success: false, 16 | data: error.message, 17 | } 18 | } 19 | } 20 | 21 | async updateUser({ 22 | id, 23 | name, 24 | }: { 25 | id: string 26 | name: string 27 | }): Promise> { 28 | try { 29 | const res = await api.put(`/users/${id}`, { 30 | id, 31 | name, 32 | }) 33 | return { 34 | success: true, 35 | data: res.data, 36 | } 37 | } catch (error: any) { 38 | return { 39 | success: false, 40 | data: error.message, 41 | } 42 | } 43 | } 44 | } 45 | 46 | export default new UsersService() 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repo is for demonstrating how you can implement service layer pattern in your next js apps. 2 | 3 | By using service layer pattern you can easily separate UI and your logic in your next app. 4 | 5 | Basically, you'll have a services folder where you create your services and create a new class instance from it. i.e. `UsersService` 6 | 7 | Functions that are in service files responsible for making api calls and return data or error. 8 | 9 | Then, you'll have a `hooks` folder where you create corresponding hook files by looking at your services. i.e. `useUser.ts` 10 | 11 | in these files, you should use react-query to use your service and get data states and return needed fields. i.e. `data, status` 12 | 13 | then you can use that hook any component you like. 14 | 15 | in just one line of code, you'll get the data in your component. 16 | 17 | You can also make server side fetching by only using the services you created. 18 | 19 | [youtube video](https://www.youtube.com/watch?v=rwTRD-p-rog) 20 | 21 | 22 | ![image](https://github.com/user-attachments/assets/a9b18300-99b9-4a95-bfe5-919d4fdf1d58) 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/user-list-loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton" 2 | 3 | export default function UserListLoading() { 4 | return ( 5 |
6 |
7 |

Users

8 |
9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {Array.from({ length: 20 }).map((_, index) => ( 20 | 24 | 27 | 30 | 33 | 34 | ))} 35 | 36 |
AvatarNameCreated At
25 | 26 | 28 | 29 | 31 | 32 |
37 |
38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/user-list.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback } from "@/components/ui/avatar" 2 | import { User } from "@/types/user.types" 3 | 4 | export default function UserList({ data }: { data: User[] }) { 5 | return ( 6 |
7 |
8 |

Users

9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {data?.map((user) => ( 21 | 25 | 32 | 33 | 34 | 35 | ))} 36 | 37 |
AvatarNameCreated At
26 | 27 | 28 | {user.name.charAt(0).toUpperCase()} 29 | 30 | 31 | {user.name}{user.createdAt}
38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import usersService from "@/services/users.service" 2 | import { User } from "@/types/user.types" 3 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" 4 | 5 | export function useGetUsers() { 6 | const { data, status, refetch } = useQuery({ 7 | queryKey: [`users`], 8 | queryFn: () => usersService.getUsers(), 9 | staleTime: 5000, 10 | refetchOnWindowFocus: false, 11 | }) 12 | 13 | return { data, status } 14 | } 15 | 16 | export function useUpdateUser({ id, name }: { id: string; name: string }) { 17 | const queryClient = useQueryClient() 18 | 19 | const { data, status, mutate, variables, mutateAsync } = useMutation({ 20 | mutationKey: [`update-user`, id], 21 | mutationFn: (variables: { id: string; name: string }) => 22 | usersService.updateUser({ 23 | name: variables.name, 24 | id: variables.id, 25 | }), 26 | onMutate: async (userPayload) => { 27 | await queryClient.cancelQueries({ queryKey: ["users"] }) 28 | 29 | // Snapshot the previous value 30 | const previousUsers = queryClient.getQueryData(["users"]) 31 | 32 | // Optimistically update to the new value 33 | queryClient.setQueryData(["users"], (old: User[]) => [ 34 | ...old, 35 | userPayload, 36 | ]) 37 | 38 | // Return a context object with the snapshotted value 39 | return { previousUsers } 40 | }, 41 | // If the mutation fails, 42 | // use the context returned from onMutate to roll back 43 | onError: (err, newUser, context) => { 44 | queryClient.setQueryData(["users"], context?.previousUsers) 45 | }, 46 | // Always refetch after error or success: 47 | onSettled: () => { 48 | queryClient.invalidateQueries({ queryKey: ["users"] }) 49 | }, 50 | }) 51 | 52 | return { data, mutate, mutateAsync, status } 53 | } 54 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 222.2 47.4% 11.2%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 222.2 84% 4.9%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 222.2 84% 4.9%; 36 | --foreground: 210 40% 98%; 37 | --card: 222.2 84% 4.9%; 38 | --card-foreground: 210 40% 98%; 39 | --popover: 222.2 84% 4.9%; 40 | --popover-foreground: 210 40% 98%; 41 | --primary: 210 40% 98%; 42 | --primary-foreground: 222.2 47.4% 11.2%; 43 | --secondary: 217.2 32.6% 17.5%; 44 | --secondary-foreground: 210 40% 98%; 45 | --muted: 217.2 32.6% 17.5%; 46 | --muted-foreground: 215 20.2% 65.1%; 47 | --accent: 217.2 32.6% 17.5%; 48 | --accent-foreground: 210 40% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 210 40% 98%; 51 | --border: 217.2 32.6% 17.5%; 52 | --input: 217.2 32.6% 17.5%; 53 | --ring: 212.7 26.8% 83.9%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | * { 64 | @apply border-border; 65 | } 66 | body { 67 | @apply bg-background text-foreground; 68 | } 69 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config --------------------------------------------------------------------------------