├── .env ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── docker-compose.yml ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prisma ├── migrations │ ├── 20230812180126_ │ │ └── migration.sql │ ├── 20230812182754_add_role_column │ │ └── migration.sql │ ├── 20230814113016_create_post_table │ │ └── migration.sql │ ├── 20230815180839_change_naming_of_the_publish_column_to_is_published │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── api-doc │ │ ├── page.tsx │ │ └── react-swagger.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── posts │ │ │ ├── [id] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── register │ │ │ └── route.ts │ ├── auth │ │ ├── sign-in │ │ │ └── page.tsx │ │ └── sign-up │ │ │ └── page.tsx │ ├── dashboard │ │ └── page.tsx │ ├── error.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── unauthorized │ │ └── page.tsx ├── components │ ├── authentication │ │ └── sign-out-button.tsx │ ├── general │ │ ├── button.tsx │ │ ├── icon │ │ │ ├── icon.tsx │ │ │ └── icons │ │ │ │ ├── bars-icon.tsx │ │ │ │ ├── github-icon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── moon-filled-icon.tsx │ │ │ │ ├── search-icon.tsx │ │ │ │ ├── sun-filled-icon.tsx │ │ │ │ ├── user-icon.tsx │ │ │ │ └── x-icon.tsx │ │ └── theme-switch.tsx │ ├── layout │ │ ├── footer.tsx │ │ └── navbar │ │ │ ├── index.tsx │ │ │ └── nav-auth-menu.tsx │ └── posts │ │ └── list-posts.tsx ├── config │ ├── routes.ts │ └── site.tsx ├── context │ └── providers.tsx ├── hooks │ └── use-breakpoint.tsx ├── lib │ ├── prisma.ts │ └── swagger.ts ├── middleware.ts ├── types │ ├── index.ts │ └── next-auth.d.ts └── utils │ ├── auth-options.ts │ ├── auth.ts │ └── env.ts ├── tailwind.config.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # DATABASE 3 | # =================================== 4 | # DATABASE_URL=postgresql://postgres@localhost:5432/webapp_dev 5 | # =================================== 6 | 7 | # =================================== 8 | # NEXT AUTH 9 | # =================================== 10 | # NEXTAUTH_URL=http://localhost:3000 11 | # NEXTAUTH_SECRET= 12 | # NEXT_PUBLIC_PROJECT_ENV= 13 | # =================================== 14 | 15 | # =================================== 16 | # VERCEL 17 | # =================================== 18 | # NEXT_PUBLIC_PROJECT_ENV= 19 | # =================================== -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": "warn", 6 | "no-console": "warn" 7 | }, 8 | "overrides": [ 9 | { 10 | "files": ["pages/api/**/*.ts"], 11 | "plugins": ["jsdoc"], 12 | "rules": { 13 | "jsdoc/no-missing-syntax": [ 14 | "error", 15 | { 16 | "contexts": [ 17 | { 18 | "comment": "JsdocBlock:has(JsdocTag[tag=swagger])", 19 | "context": "any", 20 | "message": "@swagger documentation is required on each API. Check this out for syntax info: https://github.com/jellydn/next-swagger-doc" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [damla] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: damla 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: damla 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Related Issue 2 | 3 | - 4 | 5 | ## Description 6 | 7 | - 8 | 9 | 10 | ## Screenshots 11 | 12 | ### Desktop 13 | 14 | ### Mobile 15 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # example: [#61]feat: add new avatar 5 | if ! head -1 "$1" | grep -qE "^(\[.+?\])?(feat|fix|chore|docs|test|style|refactor|perf|build|ci|revert): .{1,}$"; then 6 | echo "🚨 Aborting commit. Your commit message is invalid. 🧐" >&2 7 | exit 1 8 | fi 9 | if ! head -1 "$1" | grep -qE "^.{1,88}$"; then 10 | echo "🚨 Aborting commit. Your commit message is too long. ✂️" >&2 11 | exit 1 12 | fi -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Type check TypeScript files 3 | '**/*.(ts|tsx)': () => 'pnpm tsc --noEmit', 4 | 5 | // Lint & Prettify TS and JS files 6 | '**/*.(ts|tsx|js)': filenames => [ 7 | `pnpm eslint ${filenames.join(' ')}`, 8 | `pnpm prettier --write ${filenames.join(' ')}` 9 | ], 10 | 11 | // Prettify only Markdown and JSON files 12 | '**/*.(md|json)': filenames => `pnpm prettier --write ${filenames.join(' ')}` 13 | }; 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@nextui-org/* -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | .cache 3 | package-lock.json 4 | public 5 | node_modules 6 | next-env.d.ts 7 | next.config.ts 8 | yarn.lock 9 | pnpm-lock.yaml 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid", 6 | "proseWrap": "preserve", 7 | "quoteProps": "as-needed", 8 | "bracketSameLine": false, 9 | "bracketSpacing": true, 10 | "tabWidth": 2 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Damla Köksal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NextJS Application Using App Router 2 | 3 | ## ⚠️ In Progress 4 | 5 | - If you want to follow up on the updates and contribute, please check the [project board.](https://github.com/users/damla/projects/7/views/4) 6 | 7 | ## Installation Settings 8 | 9 | ✔ What is your project named? … nextjs-w-app-router-starter 10 | 11 | ✔ Would you like to use TypeScript with this project? … No / **Yes** 12 | 13 | ✔ Would you like to use ESLint with this project? … No / **Yes** 14 | 15 | ✔ Would you like to use Tailwind CSS with this project? … No / **Yes** 16 | 17 | ✔ Would you like to use `src/` directory with this project? … No / **Yes** 18 | 19 | ✔ Use App Router (recommended)? … No / **Yes** 20 | 21 | ✔ Would you like to customize the default import alias? … No / **Yes** 22 | 23 | ✔ What import alias would you like configured? … @/\* 24 | 25 | ## Getting Started 26 | 27 | ⚠️ For this project, the default package manager utilized is `pnpm`. If you would like to use a different one, you can modify the `.lintstagedrc.js` and `husky` files accordingly. 28 | 29 | First, run the development server: 30 | 31 | ```bash 32 | pnpm dev 33 | # or 34 | yarn dev 35 | # or 36 | npm run dev 37 | ``` 38 | 39 | Run the PostgreSQL database locally on the docker container: 40 | 41 | ```bash 42 | docker compose up 43 | ``` 44 | 45 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 46 | 47 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 48 | 49 | ⚠️ SwaggerUI does not support cookie authentication out of the box therefore, authentication need to be done on the UI manually. See: 50 | 51 | - 52 | 53 | - 54 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:15-alpine 4 | ports: 5 | - 5432:5432 6 | environment: 7 | POSTGRES_DB: webapp_dev 8 | POSTGRES_HOST_AUTH_METHOD: trust 9 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | module.exports = nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-w-app-router-starter", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "prisma generate && prisma migrate deploy && next build", 7 | "postinstall": "prisma generate", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "prepare": "husky install", 11 | "migrate-deploy:postgres": "dotenv -e .env.development.local -- pnpm prisma migrate deploy", 12 | "migrate-dev:postgres": "dotenv -e .env.development.local -- pnpm prisma migrate dev", 13 | "reset:postgres": "dotenv -e .env.development.local -- pnpm prisma migrate reset", 14 | "seed:postgres": "dotenv -e .env.development.local -- pnpm prisma db seed", 15 | "pull:postgres": "dotenv -e .env.development.local -- pnpm prisma db pull" 16 | }, 17 | "prisma": { 18 | "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" 19 | }, 20 | "dependencies": { 21 | "@auth/prisma-adapter": "^1.0.1", 22 | "@headlessui/react": "^1.7.17", 23 | "@prisma/client": "^5.1.1", 24 | "autoprefixer": "10.4.15", 25 | "bcrypt": "^5.1.0", 26 | "clsx": "^2.0.0", 27 | "eslint": "8.47.0", 28 | "eslint-config-next": "13.4.19", 29 | "next": "13.4.19", 30 | "next-auth": "^4.23.1", 31 | "next-swagger-doc": "^0.4.0", 32 | "openapi-types": "^12.1.3", 33 | "postcss": "8.4.28", 34 | "react": "18.2.0", 35 | "react-dom": "18.2.0", 36 | "swagger-ui-react": "^5.3.1", 37 | "tailwindcss": "3.3.3", 38 | "typescript": "5.2.2" 39 | }, 40 | "devDependencies": { 41 | "@types/bcrypt": "^5.0.0", 42 | "@types/node": "20.5.4", 43 | "@types/react": "18.2.21", 44 | "@types/react-dom": "18.2.7", 45 | "@types/swagger-ui-react": "^4.18.0", 46 | "eslint-config-prettier": "^8.8.0", 47 | "eslint-plugin-jsdoc": "^46.4.6", 48 | "eslint-plugin-prettier": "^4.2.1", 49 | "husky": "^8.0.0", 50 | "lint-staged": "^13.2.3", 51 | "next-themes": "^0.2.1", 52 | "prettier": "^2.8.8", 53 | "prisma": "^5.1.1", 54 | "ts-node": "^10.9.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20230812180126_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT, 5 | "email" TEXT, 6 | "address" TEXT, 7 | "password" TEXT NOT NULL, 8 | "image" TEXT, 9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updatedAt" TIMESTAMP(3) NOT NULL, 11 | 12 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateIndex 16 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 17 | -------------------------------------------------------------------------------- /prisma/migrations/20230812182754_add_role_column/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230814113016_create_post_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Post" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "title" VARCHAR(255) NOT NULL, 7 | "content" TEXT, 8 | "published" BOOLEAN NOT NULL DEFAULT false, 9 | "authorId" TEXT NOT NULL, 10 | 11 | CONSTRAINT "Post_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- AddForeignKey 15 | ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 16 | -------------------------------------------------------------------------------- /prisma/migrations/20230815180839_change_naming_of_the_publish_column_to_is_published/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `published` on the `Post` table. All the data in the column will be lost. 5 | - The `role` column on the `User` table would be dropped and recreated. This will lead to data loss if there is data in the column. 6 | 7 | */ 8 | -- CreateEnum 9 | CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); 10 | 11 | -- AlterTable 12 | ALTER TABLE "Post" DROP COLUMN "published", 13 | ADD COLUMN "isPublished" BOOLEAN NOT NULL DEFAULT false; 14 | 15 | -- AlterTable 16 | ALTER TABLE "User" DROP COLUMN "role", 17 | ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER'; 18 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id String @id @default(cuid()) 12 | name String? 13 | email String? @unique 14 | address String? 15 | password String 16 | image String? 17 | role Role @default(USER) 18 | posts Post[] 19 | createdAt DateTime @default(now()) 20 | updatedAt DateTime @updatedAt 21 | } 22 | 23 | model Post { 24 | id String @id @default(cuid()) 25 | createdAt DateTime @default(now()) 26 | updatedAt DateTime @updatedAt 27 | title String @db.VarChar(255) 28 | content String? 29 | isPublished Boolean @default(false) 30 | author User @relation(fields: [authorId], references: [id]) 31 | authorId String 32 | } 33 | 34 | enum Role { 35 | USER 36 | ADMIN 37 | } 38 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import bcrypt from 'bcrypt'; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | async function main() { 7 | const hashedPassword = await bcrypt.hash('example-password', 10); 8 | 9 | await prisma.user.upsert({ 10 | where: { email: 'damla@user.com' }, 11 | update: {}, 12 | create: { 13 | name: 'Damla', 14 | email: 'damla@user.com', 15 | password: hashedPassword, 16 | address: 'Istanbul Turkey' 17 | } 18 | }); 19 | } 20 | 21 | main() 22 | .then(async () => { 23 | await prisma.$disconnect(); 24 | }) 25 | .catch(async () => { 26 | await prisma.$disconnect(); 27 | process.exit(1); 28 | }); 29 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api-doc/page.tsx: -------------------------------------------------------------------------------- 1 | import ReactSwagger from './react-swagger'; 2 | import { getApiDocs } from '@/lib/swagger'; 3 | 4 | export default async function ApiDocPage() { 5 | const spec = await getApiDocs(); 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/api-doc/react-swagger.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import 'swagger-ui-react/swagger-ui.css'; 4 | 5 | import SwaggerUI from 'swagger-ui-react'; 6 | 7 | type Props = { 8 | spec: Record; 9 | }; 10 | 11 | function ReactSwagger({ spec }: Props) { 12 | return ; 13 | } 14 | 15 | export default ReactSwagger; 16 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { authOptions } from '@/utils/auth-options'; 3 | 4 | const handler = NextAuth(authOptions); 5 | 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /src/app/api/posts/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { Role } from '@prisma/client'; 4 | import { getToken } from 'next-auth/jwt'; 5 | import { prisma } from '@/lib/prisma'; 6 | 7 | /** 8 | * @swagger 9 | * /api/posts/{id}: 10 | * get: 11 | * description: Returns the post data with the specific id 12 | * parameters: 13 | * - in: path 14 | * name: id 15 | * required: true 16 | * description: UUID of the post to retrieve. 17 | * schema: 18 | * type: string 19 | * responses: 20 | * 200: 21 | * description: Success 22 | * 404: 23 | * description: No post with the ID found 24 | * 401: 25 | * description: Unauthorized 26 | * 500: 27 | * description: Internal Server Error 28 | */ 29 | export async function GET( 30 | request: Request, 31 | { params }: { params: { id: string } } 32 | ) { 33 | try { 34 | const id = params.id; 35 | const post = await prisma.post.findUnique({ 36 | where: { 37 | id 38 | } 39 | }); 40 | 41 | if (!post) { 42 | return new NextResponse('No post with the ID found', { 43 | status: 404 44 | }); 45 | } 46 | return NextResponse.json(post); 47 | } catch (error: any) { 48 | return new NextResponse(error.message, { status: 500 }); 49 | } 50 | } 51 | 52 | /** 53 | * @swagger 54 | * /api/posts/{id}: 55 | * patch: 56 | * description: Update the post data 57 | * parameters: 58 | * - in: path 59 | * name: id 60 | * required: true 61 | * description: UUID of the post to retrieve. 62 | * schema: 63 | * type: string 64 | * responses: 65 | * 200: 66 | * description: Success 67 | * 404: 68 | * description: No post with the ID found 69 | * 401: 70 | * description: Unauthorized 71 | * 500: 72 | * description: Internal Server Error 73 | */ 74 | export async function PATCH( 75 | request: NextRequest, 76 | { params }: { params: { id: string } } 77 | ) { 78 | try { 79 | const token = await getToken({ 80 | req: request, 81 | secret: process.env.NEXTAUTH_SECRET 82 | }); 83 | 84 | if (token?.role === Role.ADMIN) { 85 | const id = params.id; 86 | let json = await request.json(); 87 | 88 | const updated_post = await prisma.post.update({ 89 | where: { id }, 90 | data: json 91 | }); 92 | 93 | if (!updated_post) { 94 | return new NextResponse('No post with the ID found', { status: 404 }); 95 | } 96 | 97 | return NextResponse.json(updated_post); 98 | } 99 | 100 | return new NextResponse('Unauthorized', { status: 401 }); 101 | } catch (error: any) { 102 | return new NextResponse(error.message, { status: 500 }); 103 | } 104 | } 105 | 106 | /** 107 | * @swagger 108 | * /api/posts/{id}: 109 | * delete: 110 | * description: Delete the post data 111 | * parameters: 112 | * - in: path 113 | * name: id 114 | * required: true 115 | * description: UUID of the post to retrieve. 116 | * schema: 117 | * type: string 118 | * responses: 119 | * 204: 120 | * description: Success 121 | * 401: 122 | * description: Unauthorized 123 | * 404: 124 | * description: No post with ID found 125 | * 500: 126 | * description: Internal Server Error 127 | */ 128 | export async function DELETE( 129 | request: NextRequest, 130 | { params }: { params: { id: string } } 131 | ) { 132 | try { 133 | const token = await getToken({ 134 | req: request, 135 | secret: process.env.NEXTAUTH_SECRET 136 | }); 137 | if (token?.role === Role.ADMIN) { 138 | const id = params.id; 139 | await prisma.post.delete({ 140 | where: { id } 141 | }); 142 | 143 | return new NextResponse(null, { status: 204 }); 144 | } 145 | return new NextResponse('Unauthorized', { status: 401 }); 146 | } catch (error: any) { 147 | if (error.code === 'P2025') { 148 | return new NextResponse('No post with the ID found', { status: 404 }); 149 | } 150 | 151 | return new NextResponse(error.message, { status: 500 }); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/app/api/posts/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { Role } from '@prisma/client'; 4 | import { getToken } from 'next-auth/jwt'; 5 | import { prisma } from '@/lib/prisma'; 6 | 7 | /** 8 | * @swagger 9 | * /api/posts: 10 | * get: 11 | * description: Returns the posts list 12 | * responses: 13 | * 200: 14 | * description: Success 15 | * 500: 16 | * description: Internal Server Error 17 | */ 18 | export async function GET(request: Request) { 19 | try { 20 | const posts = await prisma.post.findMany(); 21 | 22 | return NextResponse.json(posts); 23 | } catch (error: any) { 24 | return new NextResponse(error.message, { status: 500 }); 25 | } 26 | } 27 | 28 | /** 29 | * @swagger 30 | * /api/posts: 31 | * post: 32 | * description: Creates a new post 33 | * requestBody: 34 | * required: true 35 | * content: 36 | * application/json: 37 | * schema: 38 | * $ref: '#/components/schemas/initPostApi' 39 | * responses: 40 | * 201: 41 | * description: Success 42 | * 401: 43 | * description: Unauthorized 44 | * 500: 45 | * description: Internal server error 46 | */ 47 | export async function POST(request: NextRequest) { 48 | try { 49 | const token = await getToken({ 50 | req: request, 51 | secret: process.env.NEXTAUTH_SECRET 52 | }); 53 | 54 | if (token?.role === Role.ADMIN) { 55 | const json = await request.json(); 56 | const post = await prisma.post.create({ 57 | data: { authorId: token.id, ...json } 58 | }); 59 | 60 | return new NextResponse(JSON.stringify(post), { 61 | status: 201, 62 | headers: { 'Content-Type': 'application/json' } 63 | }); 64 | } 65 | 66 | return new NextResponse('Unauthorized', { status: 401 }); 67 | } catch (error: any) { 68 | return new NextResponse(error.message, { status: 500 }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/api/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import bcrypt from 'bcrypt'; 3 | import { prisma } from '@/lib/prisma'; 4 | 5 | export async function POST(request: Request) { 6 | try { 7 | const body = await request.json(); 8 | const { name, email, address, password } = body.data; 9 | 10 | if (!name || !email || !address || !password) { 11 | return new NextResponse('Missing field found', { 12 | status: 422 13 | }); 14 | } 15 | const exist = await prisma.user.findUnique({ where: { email: email } }); 16 | 17 | if (exist) { 18 | return new NextResponse('User already exist', { 19 | status: 400 20 | }); 21 | } 22 | 23 | const hashedPassword = await bcrypt.hash(password, 10); 24 | 25 | const user = await prisma.user.create({ 26 | data: { 27 | name, 28 | email, 29 | address, 30 | password: hashedPassword 31 | } 32 | }); 33 | 34 | return NextResponse.json(user); 35 | } catch (error: any) { 36 | return NextResponse.json({ error: error.message }, { status: 500 }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/auth/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/general/button'; 4 | import { signIn } from 'next-auth/react'; 5 | import { useRouter } from 'next/navigation'; 6 | import { useState } from 'react'; 7 | 8 | interface CredentialData { 9 | email: string; 10 | password: string; 11 | } 12 | 13 | export default function SignInPage() { 14 | const router = useRouter(); 15 | const [data, setData] = useState({ email: '', password: '' }); 16 | const [error, setError] = useState(); 17 | 18 | const loginUser = async (e: React.FormEvent) => { 19 | e.preventDefault(); 20 | 21 | const response = await signIn('credentials', { 22 | ...data, 23 | redirect: false 24 | }); 25 | if (!response?.error) { 26 | router.refresh(); 27 | router.push('/'); 28 | } else { 29 | setError(response.error); 30 | resetForm(); 31 | } 32 | }; 33 | 34 | const resetForm = () => { 35 | setData({ email: '', password: '' }); 36 | }; 37 | 38 | // https://tailwindui.com/components/application-ui/forms/sign-in-forms 39 | return ( 40 |
41 | {error &&

{error}

} 42 |
43 |

44 | Sign in to your account 45 |

46 |
47 | 48 |
49 |
50 |
51 | 57 |
58 | { 66 | setData({ ...data, email: e.target.value }); 67 | }} 68 | className="px-4 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 69 | /> 70 |
71 |
72 | 73 |
74 |
75 | 81 |
82 |
83 | { 91 | setData({ ...data, password: e.target.value }); 92 | }} 93 | className="px-4 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 94 | /> 95 |
96 |
97 | 98 |
99 | 105 |
106 |
107 |
108 |
109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /src/app/auth/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/general/button'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useState } from 'react'; 6 | 7 | interface UserData { 8 | name: string; 9 | email: string; 10 | address: string; 11 | password: string; 12 | } 13 | 14 | export default function SignUpPage() { 15 | const router = useRouter(); 16 | 17 | const [error, setError] = useState(); 18 | const [data, setData] = useState({ 19 | name: '', 20 | email: '', 21 | address: '', 22 | password: '' 23 | }); 24 | 25 | const registerUser = async (e: React.FormEvent) => { 26 | e.preventDefault(); 27 | 28 | const response = await fetch('/api/register', { 29 | method: 'POST', 30 | headers: { 'Content-Type': 'application/json' }, 31 | body: JSON.stringify({ data }) 32 | }); 33 | if (response.ok) { 34 | router.refresh(); 35 | router.push('/'); 36 | } else { 37 | const error = await response.text(); 38 | setError(error); 39 | resetForm(); 40 | } 41 | }; 42 | 43 | const resetForm = () => { 44 | setData({ 45 | name: '', 46 | email: '', 47 | address: '', 48 | password: '' 49 | }); 50 | }; 51 | // https://tailwindui.com/components/application-ui/forms/sign-in-forms 52 | return ( 53 |
54 | {error &&

{error}

} 55 |
56 |

57 | Register 58 |

59 |
60 | 61 |
62 |
63 |
64 | 70 |
71 | { 78 | setData({ ...data, name: e.target.value }); 79 | }} 80 | className="px-4 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 81 | /> 82 |
83 |
84 | 85 |
86 | 92 |
93 | { 101 | setData({ ...data, email: e.target.value }); 102 | }} 103 | className="px-4 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 104 | /> 105 |
106 |
107 | 108 |
109 | 115 |
116 | { 124 | setData({ ...data, address: e.target.value }); 125 | }} 126 | className="px-4 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 127 | /> 128 |
129 |
130 | 131 |
132 |
133 | 139 |
140 |
141 | { 149 | setData({ ...data, password: e.target.value }); 150 | }} 151 | className="px-4 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 152 | /> 153 |
154 |
155 | 156 |
157 | 163 |
164 |
165 |
166 |
167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/general/button'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useSession } from 'next-auth/react'; 6 | import { useState } from 'react'; 7 | 8 | interface PostData { 9 | title: string; 10 | content: string; 11 | isPublished: boolean; 12 | } 13 | 14 | export default function Dashboard() { 15 | const router = useRouter(); 16 | const { data: session, update } = useSession(); 17 | 18 | const [error, setError] = useState(''); 19 | const [newName, setNewName] = useState(''); 20 | const [post, setPost] = useState({ 21 | title: '', 22 | content: '', 23 | isPublished: false 24 | }); 25 | 26 | const addPost = async (e: React.FormEvent) => { 27 | e.preventDefault(); 28 | 29 | const response = await fetch('/api/posts', { 30 | method: 'POST', 31 | headers: { 'Content-Type': 'application/json' }, 32 | body: JSON.stringify({ ...post }) 33 | }); 34 | if (response.ok) { 35 | router.refresh(); 36 | router.push('/'); 37 | } else { 38 | const error = await response.text(); 39 | setError(error); 40 | resetForm(); 41 | } 42 | }; 43 | 44 | const resetForm = () => { 45 | setPost({ 46 | title: '', 47 | content: '', 48 | isPublished: false 49 | }); 50 | }; 51 | 52 | return ( 53 |
54 |

Dashboard

55 |

Hi {session?.user?.name}

56 |

Address: {session?.user?.address}

57 | 58 | setNewName(e.target.value)} 64 | /> 65 | 71 |
75 |
76 | 77 | { 84 | setPost({ ...post, title: e.target.value }); 85 | }} 86 | /> 87 |
88 |
89 | 90 | { 97 | setPost({ ...post, content: e.target.value }); 98 | }} 99 | /> 100 |
101 |
102 | { 107 | setPost({ ...post, isPublished: e.target.checked }); 108 | }} 109 | /> 110 | 111 |
112 | {error &&

{error}

} 113 | 119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export default function Error({ 4 | error, 5 | reset 6 | }: { 7 | error: Error; 8 | reset: () => void; 9 | }) { 10 | return ( 11 |
12 |

Something went wrong!

13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damla/nextjs-w-app-router-starter/094f422d8445817fb68d25b59c4a6f7e6b2a04d0/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css'; 2 | 3 | import Footer from '@/components/layout/footer'; 4 | import { Inter } from 'next/font/google'; 5 | import { Metadata } from 'next'; 6 | import Navbar from '@/components/layout/navbar'; 7 | import Providers from '../context/providers'; 8 | import clsx from 'clsx'; 9 | import { siteConfig } from '@/config/site'; 10 | 11 | const inter = Inter({ subsets: ['latin'] }); 12 | 13 | export const metadata: Metadata = { 14 | title: { 15 | default: siteConfig.name, 16 | template: `%s - ${siteConfig.name}` 17 | }, 18 | description: siteConfig.description, 19 | themeColor: [ 20 | { media: '(prefers-color-scheme: light)', color: 'white' }, 21 | { media: '(prefers-color-scheme: dark)', color: 'black' } 22 | ], 23 | icons: { 24 | icon: '/favicon.ico', 25 | shortcut: '/favicon-16x16.png', 26 | apple: '/apple-touch-icon.png' 27 | } 28 | }; 29 | 30 | interface Props { 31 | children: React.ReactNode; 32 | } 33 | 34 | export default function RootLayout({ children }: Props) { 35 | return ( 36 | 37 | 38 | 39 |
40 | 41 |
42 | {children} 43 |
44 |
45 |
46 |
47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { ListPosts } from '@/components/posts/list-posts'; 2 | import { Suspense } from 'react'; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 |

Posts

8 |
9 | Loading...
}> 10 | 11 | 12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/unauthorized/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | 5 | export default function ServerPage() { 6 | return ( 7 |
8 |

9 | You are unauthorized to view this page. Go back to{' '} 10 | 11 | Home Page. 12 | 13 |

14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/authentication/sign-out-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/general/button'; 4 | import { signOut } from 'next-auth/react'; 5 | 6 | interface Props { 7 | className?: string; 8 | } 9 | 10 | export function SignOutButton({ className }: Props) { 11 | const handleSignOut = () => signOut({ callbackUrl: '/' }); 12 | return ( 13 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/general/button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ReactNode } from 'react'; 4 | 5 | interface Props { 6 | type?: 'button' | 'reset' | 'submit'; 7 | onClick?: VoidFunction; 8 | className?: string; 9 | children: ReactNode; 10 | } 11 | 12 | export function Button({ 13 | type = 'button', 14 | onClick, 15 | className, 16 | children 17 | }: Props) { 18 | return ( 19 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/general/icon/icon.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { iconNameMap } from './icons'; 3 | 4 | export type IconName = keyof typeof iconNameMap; 5 | 6 | interface Props { 7 | name: IconName; 8 | size?: number; 9 | className?: string; 10 | } 11 | 12 | export function Icon({ name, size, className }: Props) { 13 | const Component = iconNameMap[name]; 14 | 15 | return ( 16 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/general/icon/icons/bars-icon.tsx: -------------------------------------------------------------------------------- 1 | const BarsIcon = () => ( 2 | 9 | 14 | 15 | ); 16 | 17 | export default BarsIcon; 18 | -------------------------------------------------------------------------------- /src/components/general/icon/icons/github-icon.tsx: -------------------------------------------------------------------------------- 1 | const GithubIcon = () => ( 2 | 3 | 9 | 10 | ); 11 | 12 | export default GithubIcon; 13 | -------------------------------------------------------------------------------- /src/components/general/icon/icons/index.ts: -------------------------------------------------------------------------------- 1 | import BarsIcon from './bars-icon'; 2 | import GithubIcon from './github-icon'; 3 | import MoonFilledIcon from './moon-filled-icon'; 4 | import SearchIcon from './search-icon'; 5 | import SunFilledIcon from './sun-filled-icon'; 6 | import UserIcon from './user-icon'; 7 | import XIcon from './x-icon'; 8 | 9 | export const iconNameMap = { 10 | BarsIcon, 11 | GithubIcon, 12 | MoonFilledIcon, 13 | SearchIcon, 14 | SunFilledIcon, 15 | UserIcon, 16 | XIcon 17 | } as const; 18 | -------------------------------------------------------------------------------- /src/components/general/icon/icons/moon-filled-icon.tsx: -------------------------------------------------------------------------------- 1 | const MoonFilledIcon = () => ( 2 | 3 | 7 | 8 | ); 9 | 10 | export default MoonFilledIcon; 11 | -------------------------------------------------------------------------------- /src/components/general/icon/icons/search-icon.tsx: -------------------------------------------------------------------------------- 1 | const SearchIcon = () => ( 2 | 25 | ); 26 | 27 | export default SearchIcon; 28 | -------------------------------------------------------------------------------- /src/components/general/icon/icons/sun-filled-icon.tsx: -------------------------------------------------------------------------------- 1 | const SunFilledIcon = () => ( 2 | 3 | 4 | 5 | 6 | 7 | 8 | ); 9 | 10 | export default SunFilledIcon; 11 | -------------------------------------------------------------------------------- /src/components/general/icon/icons/user-icon.tsx: -------------------------------------------------------------------------------- 1 | const UserIcon = () => ( 2 | 9 | 14 | 15 | ); 16 | 17 | export default UserIcon; 18 | -------------------------------------------------------------------------------- /src/components/general/icon/icons/x-icon.tsx: -------------------------------------------------------------------------------- 1 | const XIcon = () => ( 2 | 9 | 14 | 15 | ); 16 | 17 | export default XIcon; 18 | -------------------------------------------------------------------------------- /src/components/general/theme-switch.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { Icon } from './icon/icon'; 4 | import { useTheme } from 'next-themes'; 5 | 6 | const ThemeSwitch = () => { 7 | const [mounted, setMounted] = useState(false); 8 | const { theme, setTheme } = useTheme(); 9 | 10 | useEffect(() => { 11 | setMounted(true); 12 | }, []); 13 | 14 | if (!mounted) { 15 | return null; 16 | } 17 | 18 | return ( 19 | 27 | ); 28 | }; 29 | 30 | export default ThemeSwitch; 31 | -------------------------------------------------------------------------------- /src/components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { Links } from '@/config/routes'; 5 | 6 | export default function Footer() { 7 | return ( 8 |
9 | ©{new Date().getFullYear()} 10 | 11 | Damla Köksal. 12 | 13 | All rights reserved. 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/layout/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Disclosure, Menu, Transition } from '@headlessui/react'; 4 | 5 | import { Fragment } from 'react'; 6 | import { Icon } from '@/components/general/icon/icon'; 7 | import { NavbarAuthMenu } from './nav-auth-menu'; 8 | import ThemeSwitch from '@/components/general/theme-switch'; 9 | import clsx from 'clsx'; 10 | 11 | export default function Navbar() { 12 | return ( 13 | 14 | {({ open }) => ( 15 | <> 16 |
17 |
18 |
19 | {/* Mobile menu button*/} 20 | 21 | 22 | Open main menu 23 | {open ? ( 24 | 29 |
30 |
31 |
32 |

33 | Next.JS Starter 34 |

35 |
36 |
37 | 38 |
39 |
40 |
41 | 42 | {/* Profile dropdown */} 43 | 44 |
45 | 46 | 47 | Open user menu 48 | 53 | 54 |
55 | 64 | 65 | 66 | {({ active }) => ( 67 | 74 | Your Profile 75 | 76 | )} 77 | 78 | 79 | {({ active }) => ( 80 | 87 | Settings 88 | 89 | )} 90 | 91 | 92 | {({ active }) => ( 93 | 100 | Sign out 101 | 102 | )} 103 | 104 | 105 | 106 |
107 |
108 |
109 |
110 | 111 | 112 | 113 | 114 | 115 | )} 116 |
117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /src/components/layout/navbar/nav-auth-menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Fragment, useEffect, useState } from 'react'; 4 | import { isAdmin, isUser } from '@/utils/auth'; 5 | 6 | import { Disclosure } from '@headlessui/react'; 7 | import { NavItem } from '@/types'; 8 | import { siteConfig } from '@/config/site'; 9 | import { useSession } from 'next-auth/react'; 10 | 11 | function renderNavItems(navItems: NavItem[], keyPrefix: string) { 12 | return ( 13 |
14 | {navItems.map((item, idx) => ( 15 | 16 | {item.href && ( 17 | 21 | {item.label} 22 | 23 | )} 24 | {item.button && ( 25 |
26 | {item.button} 27 |
28 | )} 29 |
30 | ))} 31 |
32 | ); 33 | } 34 | 35 | function renderMobileItems(navItems: NavItem[], keyPrefix: string) { 36 | return ( 37 |
38 | {navItems.map((item, idx) => ( 39 | 40 | {item.href && ( 41 | 46 | {item.label} 47 | 48 | )} 49 | {item.button && ( 50 |
51 | {item.button} 52 |
53 | )} 54 |
55 | ))} 56 |
57 | ); 58 | } 59 | 60 | interface Props { 61 | mobile?: boolean; 62 | } 63 | 64 | export function NavbarAuthMenu({ mobile }: Props) { 65 | const { data: session } = useSession(); 66 | const [navItems, setNavItems] = useState([]); 67 | const [userType, setUserType] = useState('public'); 68 | 69 | function fetchUserType() { 70 | if (session && isAdmin(session)) return 'admin'; 71 | if (session && isUser(session)) return 'user'; 72 | return 'public'; 73 | } 74 | 75 | useEffect(() => { 76 | const userType = fetchUserType(); 77 | setUserType(userType); 78 | setNavItems(siteConfig.navItems[userType]); 79 | }, [session]); // eslint-disable-line react-hooks/exhaustive-deps 80 | 81 | if (mobile) 82 | return renderMobileItems(navItems, `mobile-${userType}-menu-item`); 83 | 84 | return renderNavItems(navItems, `${userType}-menu-item`); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/posts/list-posts.tsx: -------------------------------------------------------------------------------- 1 | import { Post } from '@prisma/client'; 2 | import { url } from '@/utils/env'; 3 | 4 | async function getPosts() { 5 | try { 6 | const res = await fetch(`${url}/api/posts`); 7 | 8 | if (!res.ok) { 9 | throw new Error('Failed to fetch posts'); 10 | } 11 | 12 | return res.json(); 13 | } catch (error) { 14 | throw new Error(`Failed to fetch: ${error}`); 15 | } 16 | } 17 | 18 | export async function ListPosts() { 19 | const posts: Post[] = await getPosts(); 20 | 21 | if (posts.length === 0 || !posts) return

No Posts found.

; 22 | 23 | return ( 24 |
25 | {posts.map( 26 | ({ title, content, isPublished }, idx) => 27 | isPublished && ( 28 |
32 | {title} 33 | {content} 34 |
35 | ) 36 | )} 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/config/routes.ts: -------------------------------------------------------------------------------- 1 | export const Routes = { 2 | // AUTH 3 | SIGN_IN: '/auth/sign-in', 4 | SIGN_UP: '/auth/sign-up', 5 | 6 | // PUBLIC 7 | HOME: '/', 8 | 9 | // ADMIN 10 | DASHBOARD: '/dashboard' 11 | }; 12 | 13 | export const Links = { 14 | GITHUB: 'https://github.com/damla/nextjs-w-app-router-starter', 15 | X: 'https://x.com/damlakoksal' 16 | }; 17 | -------------------------------------------------------------------------------- /src/config/site.tsx: -------------------------------------------------------------------------------- 1 | import { Links, Routes } from './routes'; 2 | import { NavItem, SiteConfig } from '@/types'; 3 | 4 | import { SignOutButton } from '@/components/authentication/sign-out-button'; 5 | 6 | const baseNavItems: NavItem[] = [ 7 | { 8 | label: 'Home', 9 | href: Routes.HOME 10 | } 11 | ]; 12 | 13 | const publicNavItems: NavItem[] = [ 14 | ...baseNavItems, 15 | { 16 | label: 'Login', 17 | href: Routes.SIGN_IN 18 | }, 19 | { 20 | label: 'Register', 21 | href: Routes.SIGN_UP 22 | } 23 | ]; 24 | 25 | const authorizedUserNavItems: NavItem[] = [ 26 | ...baseNavItems, 27 | { 28 | label: 'Logout', 29 | button: 30 | } 31 | ]; 32 | 33 | const authorizedAdminNavItems: NavItem[] = [ 34 | { 35 | label: 'Dashboard', 36 | href: Routes.DASHBOARD 37 | } 38 | ]; 39 | 40 | export const siteConfig: SiteConfig = { 41 | name: 'Next.js w/App Router Starter', 42 | description: 'Supercharge development with Next.js with App Router Starter', 43 | navItems: { 44 | public: publicNavItems, 45 | user: authorizedUserNavItems, 46 | admin: [...authorizedUserNavItems, ...authorizedAdminNavItems] 47 | }, 48 | links: { 49 | github: Links.GITHUB, 50 | x: Links.X 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/context/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ThemeProvider as NextThemesProvider } from 'next-themes'; 4 | import { ReactNode } from 'react'; 5 | import { SessionProvider } from 'next-auth/react'; 6 | import { ThemeProviderProps } from 'next-themes/dist/types'; 7 | 8 | interface Props { 9 | children: ReactNode; 10 | themeProps?: ThemeProviderProps; 11 | } 12 | 13 | export default function Providers({ children, themeProps }: Props) { 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/use-breakpoint.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | 5 | type Breakpoints = { 6 | xs: number; 7 | sm: number; 8 | md: number; 9 | lg: number; 10 | xl: number; 11 | '2xl': number; 12 | }; 13 | 14 | type CurrentBreakpoint = keyof Breakpoints | null; 15 | 16 | const useBreakpoint = (): CurrentBreakpoint => { 17 | const breakpoints: Breakpoints = { 18 | xs: 0, 19 | sm: 640, 20 | md: 768, 21 | lg: 1024, 22 | xl: 1280, 23 | '2xl': 1536 24 | }; 25 | 26 | const [currentBreakpoint, setCurrentBreakpoint] = 27 | useState(null); 28 | 29 | useEffect(() => { 30 | const updateBreakpoint = () => { 31 | let matchedBreakpoint: CurrentBreakpoint = null; 32 | for (const [name, width] of Object.entries(breakpoints)) { 33 | if (window.matchMedia(`(min-width: ${width}px)`).matches) { 34 | matchedBreakpoint = name as keyof Breakpoints; 35 | } 36 | } 37 | setCurrentBreakpoint(matchedBreakpoint); 38 | }; 39 | 40 | updateBreakpoint(); 41 | 42 | window.addEventListener('resize', updateBreakpoint); 43 | 44 | return () => { 45 | window.removeEventListener('resize', updateBreakpoint); 46 | }; 47 | }, []); // eslint-disable-line react-hooks/exhaustive-deps 48 | 49 | return currentBreakpoint; 50 | }; 51 | 52 | export default useBreakpoint; 53 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const globalForPrisma = global as unknown as { prisma: PrismaClient }; 4 | 5 | export const prisma = 6 | globalForPrisma.prisma || 7 | new PrismaClient({ 8 | log: ['query'] 9 | }); 10 | 11 | if (process.env.NODE_ENV != 'production') globalForPrisma.prisma; 12 | -------------------------------------------------------------------------------- /src/lib/swagger.ts: -------------------------------------------------------------------------------- 1 | import { createSwaggerSpec } from 'next-swagger-doc'; 2 | 3 | export async function getApiDocs() { 4 | const spec = createSwaggerSpec({ 5 | apiFolder: 'src/app/api', 6 | definition: { 7 | openapi: '3.0.0', 8 | info: { 9 | title: 'Next Starter App API', 10 | version: '1.0' 11 | }, 12 | components: { 13 | schemas: { 14 | initPostApi: { 15 | type: 'object', 16 | properties: { 17 | title: { 18 | type: 'string', 19 | description: 'The post title', 20 | example: 'My post title' 21 | }, 22 | content: { 23 | type: 'string', 24 | description: 'The post content', 25 | example: 'My post content' 26 | }, 27 | isPublished: { 28 | type: 'boolean', 29 | description: 'The post is published or not', 30 | example: true 31 | } 32 | }, 33 | required: ['title', 'content', 'isPublished'] 34 | } 35 | }, 36 | // Swagger UI does not support cookie authentication out of the box therefore, 37 | // authentication need to be done on the UI manually. 38 | // https://swagger.io/docs/specification/authentication/cookie-authentication/ 39 | // https://github.com/swagger-api/swagger-js/issues/1163 40 | securitySchemes: { 41 | cookieAuth: { 42 | type: 'apiKey', 43 | name: 'next-auth.session-token', 44 | in: 'cookie' 45 | } 46 | } 47 | }, 48 | security: [{ cookieAuth: [] }] 49 | } 50 | }); 51 | return spec; 52 | } 53 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequestWithAuth, withAuth } from 'next-auth/middleware'; 2 | 3 | import { NextResponse } from 'next/server'; 4 | import { Role } from '@prisma/client'; 5 | 6 | export default withAuth( 7 | function middleware(request: NextRequestWithAuth) { 8 | if ( 9 | request.nextUrl.pathname.startsWith('/dashboard') && 10 | request.nextauth.token?.role !== Role.ADMIN 11 | ) { 12 | return NextResponse.rewrite(new URL('/unauthorized', request.url)); 13 | } 14 | }, 15 | { 16 | callbacks: { 17 | authorized: ({ token }) => !!token 18 | } 19 | } 20 | ); 21 | 22 | export const config = { matcher: ['/dashboard'] }; 23 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface NavItem { 2 | label: string; 3 | href?: string; 4 | button?: JSX.Element; 5 | } 6 | 7 | export interface SiteConfig { 8 | name: string; 9 | description: string; 10 | navItems: { 11 | public: NavItem[]; 12 | user: NavItem[]; 13 | admin: NavItem[]; 14 | }; 15 | links: { 16 | github: string; 17 | x: string; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSession, DefaultUser } from 'next-auth'; 2 | import { JWT, DefaultJWT } from 'next-auth/jwt'; 3 | 4 | declare module 'next-auth' { 5 | interface Session { 6 | user: { 7 | id: string; 8 | address?: string; 9 | createdAt: Date; 10 | updatedAt: Date; 11 | role: string; 12 | } & DefaultSession['user']; 13 | } 14 | 15 | interface User extends DefaultUser { 16 | address: string | null; 17 | createdAt: Date; 18 | updatedAt: Date; 19 | role: string; 20 | } 21 | } 22 | 23 | declare module 'next-auth/jwt' { 24 | interface JWT extends DefaultJWT { 25 | id: string; 26 | address: string | null; 27 | createdAt: Date; 28 | updatedAt: Date; 29 | role: string; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/auth-options.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from 'next-auth/adapters'; 2 | import CredentialsProvider from 'next-auth/providers/credentials'; 3 | import type { NextAuthOptions } from 'next-auth'; 4 | import { PrismaAdapter } from '@auth/prisma-adapter'; 5 | import { Routes } from '@/config/routes'; 6 | import bcrypt from 'bcrypt'; 7 | import { isDevelopment } from './env'; 8 | import { prisma } from '@/lib/prisma'; 9 | 10 | export const authOptions: NextAuthOptions = { 11 | adapter: PrismaAdapter(prisma) as Adapter, 12 | providers: [ 13 | CredentialsProvider({ 14 | name: 'E-mail', 15 | credentials: { 16 | email: { label: 'E-mail', type: 'email', placeholder: 'Enter e-mail' }, 17 | password: { 18 | label: 'Password', 19 | type: 'password', 20 | placeholder: 'Enter password' 21 | } 22 | }, 23 | async authorize(credentials, req) { 24 | if (!credentials || !credentials.email || !credentials.password) { 25 | return null; 26 | } 27 | 28 | const user = await prisma.user.findUnique({ 29 | where: { 30 | email: credentials.email 31 | } 32 | }); 33 | 34 | if (!user) throw new Error('User not found'); 35 | 36 | const passwordValid = await bcrypt.compare( 37 | credentials.password, 38 | user.password 39 | ); 40 | 41 | if (!passwordValid) { 42 | throw new Error('Invalid password'); 43 | } 44 | 45 | return user; 46 | } 47 | }) 48 | ], 49 | callbacks: { 50 | // JWT is triggered before session 51 | // returns the user only on sign in action 52 | async jwt({ token, user, session, trigger }) { 53 | if (trigger === 'update' && session?.name) { 54 | token.name = session.name; 55 | } 56 | 57 | if (user) { 58 | return { 59 | ...token, 60 | id: user.id, 61 | address: user.address, 62 | role: user.role 63 | }; 64 | } 65 | 66 | const newUser = await prisma.user.update({ 67 | where: { 68 | id: token.id 69 | }, 70 | data: { 71 | name: token.name 72 | } 73 | }); 74 | 75 | return token; 76 | }, 77 | async session({ session, token }) { 78 | return { 79 | ...session, 80 | user: { 81 | ...session.user, 82 | id: token.id, 83 | address: token.address, 84 | name: token.name, 85 | role: token.role 86 | } 87 | }; 88 | } 89 | }, 90 | pages: { 91 | signIn: Routes.SIGN_IN 92 | }, 93 | session: { 94 | strategy: 'jwt' 95 | }, 96 | debug: isDevelopment, 97 | secret: process.env.NEXTAUTH_SECRET 98 | }; 99 | -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '@prisma/client'; 2 | import { Session } from 'next-auth'; 3 | 4 | export function isLoggedIn(session: Session) { 5 | return session; 6 | } 7 | 8 | export function isUser(session: Session) { 9 | return session.user.role === Role.USER; 10 | } 11 | 12 | export function isAdmin(session: Session) { 13 | return session.user.role === Role.ADMIN; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | export const isProduction = process.env.PROJECT_ENV === 'production'; 2 | 3 | export const isDevelopment = process.env.PROJECT_ENV === 'development'; 4 | 5 | export const url = new URL( 6 | process.env.PROJECT_HTTP_URL ?? 'http://localhost:3000' 7 | ); 8 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}' 7 | ], 8 | theme: { 9 | extend: {} 10 | }, 11 | darkMode: 'class', 12 | plugins: [] 13 | }; 14 | export default config; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------