├── .env ├── .eslintrc.json ├── .gitignore ├── README.md ├── lib ├── auth.ts ├── database.ts └── session.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── admin │ └── index.tsx ├── api │ └── auth │ │ ├── login.tsx │ │ ├── logout.tsx │ │ └── register.tsx ├── index.tsx ├── login.tsx └── register.tsx ├── prisma ├── migrations │ ├── 20230106172937_initial_schema │ │ └── migration.sql │ ├── 20230106183032_initial_schema │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./dev.db" 2 | SECRET_COOKIE_PASSWORD=passwordpasswordpasswordpassword 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-webauthn Demo 2 | 3 | WebAuthn is cool but isn't the most straightforward thing to implement. I've created this demo so people don't have to spend multiple days tearing their hair out like I did. 4 | 5 | If you'd like to read more of a guided tour of the code, check out [my blog post](https://ianmitchell.dev/blog/nextjs-and-webauthn). 6 | 7 | ## Things to Note! 8 | 9 | This is a demo, it is not a fully developed implementation. There are several shortcuts (or bugs if you're feeling mean) in this demonstration you'll need to account for yourself - input validation, error handling, and things like that. 10 | 11 | One particularly nasty detail that deserves a special call out is this demo's error handling does not stop the browser from creating a passkey. For example, if you register with an email, log out, and try to register again with the same email, this website will show you an error **but a passkey will still be created.** The correct flow would be to have a user create a passkey and then proceed to register with an email and username in a separate step. 12 | 13 | So use this demo as a starting point, but you'll need to do more than just copy-paste to use this in an actual application. 14 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import prisma from "./database"; 2 | import type { 3 | VerifiedAuthenticationResponse, 4 | VerifiedRegistrationResponse, 5 | } from "@simplewebauthn/server"; 6 | import { 7 | verifyAuthenticationResponse, 8 | verifyRegistrationResponse, 9 | } from "@simplewebauthn/server"; 10 | import type { 11 | PublicKeyCredentialWithAssertionJSON, 12 | PublicKeyCredentialWithAttestationJSON, 13 | } from "@github/webauthn-json"; 14 | import crypto from "crypto"; 15 | import { GetServerSidePropsContext, NextApiRequest } from "next"; 16 | 17 | type SessionRequest = NextApiRequest | GetServerSidePropsContext["req"]; 18 | 19 | const HOST_SETTINGS = { 20 | expectedOrigin: process.env.VERCEL_URL ?? "http://localhost:3000", 21 | expectedRPID: process.env.RPID ?? "localhost", 22 | }; 23 | 24 | function clean(str: string) { 25 | return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 26 | } 27 | 28 | function binaryToBase64url(bytes: Uint8Array) { 29 | let str = ""; 30 | 31 | bytes.forEach((charCode) => { 32 | str += String.fromCharCode(charCode); 33 | }); 34 | 35 | return btoa(str); 36 | } 37 | 38 | export function generateChallenge() { 39 | return clean(crypto.randomBytes(32).toString("base64")); 40 | } 41 | 42 | export function isLoggedIn(request: SessionRequest) { 43 | return request.session.userId != null; 44 | } 45 | 46 | export async function register(request: NextApiRequest) { 47 | const challenge = request.session.challenge ?? ""; 48 | const credential = request.body 49 | .credential as PublicKeyCredentialWithAttestationJSON; 50 | const { email, username } = request.body; 51 | 52 | let verification: VerifiedRegistrationResponse; 53 | 54 | if (credential == null) { 55 | throw new Error("Invalid Credentials"); 56 | } 57 | 58 | try { 59 | verification = await verifyRegistrationResponse({ 60 | response: credential, 61 | expectedChallenge: challenge, 62 | requireUserVerification: true, 63 | ...HOST_SETTINGS, 64 | }); 65 | } catch (error) { 66 | console.error(error); 67 | throw error; 68 | } 69 | 70 | if (!verification.verified) { 71 | throw new Error("Registration verification failed"); 72 | } 73 | 74 | const { credentialID, credentialPublicKey } = 75 | verification.registrationInfo ?? {}; 76 | 77 | if (credentialID == null || credentialPublicKey == null) { 78 | throw new Error("Registration failed"); 79 | } 80 | 81 | const user = await prisma.user.create({ 82 | data: { 83 | email, 84 | username, 85 | credentials: { 86 | create: { 87 | externalId: clean(binaryToBase64url(credentialID)), 88 | publicKey: Buffer.from(credentialPublicKey), 89 | }, 90 | }, 91 | }, 92 | }); 93 | 94 | console.log(`Registered new user ${user.id}`); 95 | return user; 96 | } 97 | 98 | export async function login(request: NextApiRequest) { 99 | const challenge = request.session.challenge ?? ""; 100 | const credential = request.body 101 | .credential as PublicKeyCredentialWithAssertionJSON; 102 | const email = request.body.email; 103 | 104 | if (credential?.id == null) { 105 | throw new Error("Invalid Credentials"); 106 | } 107 | 108 | const userCredential = await prisma.credential.findUnique({ 109 | select: { 110 | id: true, 111 | userId: true, 112 | externalId: true, 113 | publicKey: true, 114 | signCount: true, 115 | user: { 116 | select: { 117 | email: true, 118 | }, 119 | }, 120 | }, 121 | where: { 122 | externalId: credential.id, 123 | }, 124 | }); 125 | 126 | if (userCredential == null) { 127 | throw new Error("Unknown User"); 128 | } 129 | 130 | let verification: VerifiedAuthenticationResponse; 131 | try { 132 | verification = await verifyAuthenticationResponse({ 133 | response: credential, 134 | expectedChallenge: challenge, 135 | authenticator: { 136 | credentialID: userCredential.externalId, 137 | credentialPublicKey: userCredential.publicKey, 138 | counter: userCredential.signCount, 139 | }, 140 | ...HOST_SETTINGS, 141 | }); 142 | 143 | await prisma.credential.update({ 144 | data: { 145 | signCount: verification.authenticationInfo.newCounter, 146 | }, 147 | where: { 148 | id: userCredential.id, 149 | }, 150 | }); 151 | } catch (error) { 152 | console.error(error); 153 | throw error; 154 | } 155 | 156 | if (!verification.verified || email !== userCredential.user.email) { 157 | throw new Error("Login verification failed"); 158 | } 159 | 160 | console.log(`Logged in as user ${userCredential.userId}`); 161 | return userCredential.userId; 162 | } 163 | -------------------------------------------------------------------------------- /lib/database.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | export default new PrismaClient(); 4 | -------------------------------------------------------------------------------- /lib/session.ts: -------------------------------------------------------------------------------- 1 | import type { IronSessionOptions } from "iron-session"; 2 | 3 | export const sessionOptions: IronSessionOptions = { 4 | password: process.env.SECRET_COOKIE_PASSWORD!, 5 | cookieName: "next-webauthn", 6 | cookieOptions: { 7 | secure: process.env.NODE_ENV === "production", 8 | }, 9 | }; 10 | 11 | declare module "iron-session" { 12 | interface IronSessionData { 13 | userId?: number; 14 | challenge?: string; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-webauthn", 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 | "@github/webauthn-json": "^2.0.2", 13 | "@prisma/client": "^4.8.1", 14 | "@simplewebauthn/server": "^7.0.0", 15 | "iron-session": "^6.3.1", 16 | "next": "13.1.1", 17 | "react": "18.2.0", 18 | "react-dom": "18.2.0" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "18.11.18", 22 | "@types/react": "18.0.26", 23 | "eslint": "8.31.0", 24 | "eslint-config-next": "13.1.1", 25 | "prisma": "^4.8.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pages/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import { withIronSessionSsr } from "iron-session/next"; 2 | import { InferGetServerSidePropsType } from "next"; 3 | import { Fragment } from "react"; 4 | import { isLoggedIn } from "../../lib/auth"; 5 | import { sessionOptions } from "../../lib/session"; 6 | 7 | export default function Admin({ 8 | userId, 9 | }: InferGetServerSidePropsType) { 10 | return ( 11 | 12 |

Admin

13 | User ID: {userId} 14 |
15 | 16 |
17 |
18 | ); 19 | } 20 | 21 | export const getServerSideProps = withIronSessionSsr( 22 | async ({ req: request, res: response }) => { 23 | if (!isLoggedIn(request)) { 24 | return { 25 | redirect: { 26 | destination: "/login", 27 | permanent: false, 28 | }, 29 | }; 30 | } 31 | 32 | return { 33 | props: { 34 | userId: request.session.userId ?? null, 35 | }, 36 | }; 37 | }, 38 | sessionOptions 39 | ); 40 | -------------------------------------------------------------------------------- /pages/api/auth/login.tsx: -------------------------------------------------------------------------------- 1 | import { withIronSessionApiRoute } from "iron-session/next"; 2 | import { sessionOptions } from "../../../lib/session"; 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | import { login } from "../../../lib/auth"; 5 | 6 | async function handler(request: NextApiRequest, response: NextApiResponse) { 7 | try { 8 | const userId = await login(request); 9 | request.session.userId = userId; 10 | await request.session.save(); 11 | 12 | response.json(userId); 13 | } catch (error) { 14 | response.status(500).json({ message: (error as Error).message }); 15 | } 16 | } 17 | 18 | export default withIronSessionApiRoute(handler, sessionOptions); 19 | -------------------------------------------------------------------------------- /pages/api/auth/logout.tsx: -------------------------------------------------------------------------------- 1 | import { withIronSessionApiRoute } from "iron-session/next"; 2 | import { sessionOptions } from "../../../lib/session"; 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | 5 | function handler(request: NextApiRequest, response: NextApiResponse) { 6 | request.session.destroy(); 7 | response.setHeader("location", "/"); 8 | response.statusCode = 302; 9 | response.end(); 10 | } 11 | 12 | export default withIronSessionApiRoute(handler, sessionOptions); 13 | -------------------------------------------------------------------------------- /pages/api/auth/register.tsx: -------------------------------------------------------------------------------- 1 | import { withIronSessionApiRoute } from "iron-session/next"; 2 | import { sessionOptions } from "../../../lib/session"; 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | import { register } from "../../../lib/auth"; 5 | 6 | async function handler(request: NextApiRequest, response: NextApiResponse) { 7 | try { 8 | const user = await register(request); 9 | request.session.userId = user.id; 10 | await request.session.save(); 11 | 12 | response.json({ userId: user.id }); 13 | } catch (error: unknown) { 14 | console.error((error as Error).message); 15 | response.status(500).json({ message: (error as Error).message }); 16 | } 17 | } 18 | 19 | export default withIronSessionApiRoute(handler, sessionOptions); 20 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import Link from "next/link"; 3 | 4 | export default function Home() { 5 | return ( 6 | 7 |

Next.js Webauthn Demo

8 |
    9 |
  • 10 | Register 11 |
  • 12 |
  • 13 | Login 14 |
  • 15 |
16 |

17 | 18 | Learn More 19 | 20 |

21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, Fragment, useEffect, useState } from "react"; 2 | import { supported, create, get } from "@github/webauthn-json"; 3 | import { withIronSessionSsr } from "iron-session/next"; 4 | import { generateChallenge, isLoggedIn } from "../lib/auth"; 5 | import { sessionOptions } from "../lib/session"; 6 | import { useRouter } from "next/router"; 7 | 8 | export default function Login({ challenge }: { challenge: string }) { 9 | const router = useRouter(); 10 | const [email, setEmail] = useState(""); 11 | const [error, setError] = useState(""); 12 | const [isAvailable, setIsAvailable] = useState(null); 13 | 14 | useEffect(() => { 15 | const checkAvailability = async () => { 16 | const available = 17 | await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); 18 | setIsAvailable(available && supported()); 19 | }; 20 | 21 | checkAvailability(); 22 | }, []); 23 | 24 | const onSubmit = async (event: FormEvent) => { 25 | event.preventDefault(); 26 | 27 | const credential = await get({ 28 | publicKey: { 29 | challenge, 30 | timeout: 60000, 31 | userVerification: "required", 32 | rpId: "localhost", 33 | }, 34 | }); 35 | 36 | const result = await fetch("/api/auth/login", { 37 | method: "POST", 38 | body: JSON.stringify({ email, credential }), 39 | headers: { 40 | "Content-Type": "application/json", 41 | }, 42 | }); 43 | 44 | if (result.ok) { 45 | router.push("/admin"); 46 | } else { 47 | const { message } = await result.json(); 48 | setError(message); 49 | } 50 | }; 51 | 52 | return ( 53 | 54 |

Login

55 | {isAvailable ? ( 56 |
57 | setEmail(event.target.value)} 64 | /> 65 | 66 | {error != null ?
{error}
: null} 67 |
68 | ) : ( 69 |

Sorry, webauthn is not available.

70 | )} 71 |
72 | ); 73 | } 74 | 75 | export const getServerSideProps = withIronSessionSsr(async function ({ 76 | req, 77 | res, 78 | }) { 79 | if (isLoggedIn(req)) { 80 | return { 81 | redirect: { 82 | destination: "/admin", 83 | permanent: false, 84 | }, 85 | }; 86 | } 87 | 88 | const challenge = generateChallenge(); 89 | req.session.challenge = challenge; 90 | await req.session.save(); 91 | 92 | return { props: { challenge } }; 93 | }, 94 | sessionOptions); 95 | -------------------------------------------------------------------------------- /pages/register.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, Fragment, useEffect, useState } from "react"; 2 | import { supported, create } from "@github/webauthn-json"; 3 | import { withIronSessionSsr } from "iron-session/next"; 4 | import { generateChallenge, isLoggedIn } from "../lib/auth"; 5 | import { sessionOptions } from "../lib/session"; 6 | import { useRouter } from "next/router"; 7 | 8 | export default function Register({ challenge }: { challenge: string }) { 9 | const router = useRouter(); 10 | const [username, setUsername] = useState(""); 11 | const [email, setEmail] = useState(""); 12 | const [error, setError] = useState(""); 13 | const [isAvailable, setIsAvailable] = useState(null); 14 | 15 | useEffect(() => { 16 | const checkAvailability = async () => { 17 | const available = 18 | await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); 19 | setIsAvailable(available && supported()); 20 | }; 21 | 22 | checkAvailability(); 23 | }, []); 24 | 25 | const onSubmit = async (event: FormEvent) => { 26 | event.preventDefault(); 27 | 28 | const credential = await create({ 29 | publicKey: { 30 | challenge: challenge, 31 | rp: { 32 | name: "next-webauthn", 33 | // TODO: Change 34 | id: "localhost", 35 | }, 36 | user: { 37 | id: window.crypto.randomUUID(), 38 | name: email, 39 | displayName: username, 40 | }, 41 | pubKeyCredParams: [{ alg: -7, type: "public-key" }], 42 | timeout: 60000, 43 | attestation: "direct", 44 | authenticatorSelection: { 45 | residentKey: "required", 46 | userVerification: "required", 47 | }, 48 | }, 49 | }); 50 | 51 | const result = await fetch("/api/auth/register", { 52 | method: "POST", 53 | body: JSON.stringify({ email, username, credential }), 54 | headers: { 55 | "Content-Type": "application/json", 56 | }, 57 | }); 58 | 59 | if (result.ok) { 60 | router.push("/admin"); 61 | } else { 62 | const { message } = await result.json(); 63 | setError(message); 64 | } 65 | }; 66 | 67 | return ( 68 | 69 |

Register Account

70 | {isAvailable ? ( 71 |
72 | setUsername(event.target.value)} 79 | /> 80 | setEmail(event.target.value)} 87 | /> 88 | 89 | {error != null ?
{error}
: null} 90 |
91 | ) : ( 92 |

Sorry, webauthn is not available.

93 | )} 94 |
95 | ); 96 | } 97 | 98 | export const getServerSideProps = withIronSessionSsr(async function ({ 99 | req, 100 | res, 101 | }) { 102 | if (isLoggedIn(req)) { 103 | return { 104 | redirect: { 105 | destination: "/admin", 106 | permanent: false, 107 | }, 108 | }; 109 | } 110 | 111 | const challenge = generateChallenge(); 112 | req.session.challenge = challenge; 113 | await req.session.save(); 114 | 115 | return { props: { challenge } }; 116 | }, 117 | sessionOptions); 118 | -------------------------------------------------------------------------------- /prisma/migrations/20230106172937_initial_schema/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "email" TEXT NOT NULL, 5 | "username" TEXT NOT NULL, 6 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" DATETIME NOT NULL 8 | ); 9 | 10 | -- CreateTable 11 | CREATE TABLE "Credential" ( 12 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 13 | "userId" INTEGER NOT NULL, 14 | "name" TEXT, 15 | "externalId" TEXT NOT NULL, 16 | "publicKey" TEXT NOT NULL, 17 | "signCount" INTEGER NOT NULL DEFAULT 0, 18 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 19 | "updatedAt" DATETIME NOT NULL, 20 | CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 21 | ); 22 | 23 | -- CreateIndex 24 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 25 | 26 | -- CreateIndex 27 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 28 | 29 | -- CreateIndex 30 | CREATE UNIQUE INDEX "Credential_externalId_key" ON "Credential"("externalId"); 31 | 32 | -- CreateIndex 33 | CREATE UNIQUE INDEX "Credential_publicKey_key" ON "Credential"("publicKey"); 34 | 35 | -- CreateIndex 36 | CREATE INDEX "Credential_externalId_idx" ON "Credential"("externalId"); 37 | -------------------------------------------------------------------------------- /prisma/migrations/20230106183032_initial_schema/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to alter the column `publicKey` on the `Credential` table. The data in that column could be lost. The data in that column will be cast from `String` to `Binary`. 5 | 6 | */ 7 | -- RedefineTables 8 | PRAGMA foreign_keys=OFF; 9 | CREATE TABLE "new_Credential" ( 10 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 11 | "userId" INTEGER NOT NULL, 12 | "name" TEXT, 13 | "externalId" TEXT NOT NULL, 14 | "publicKey" BLOB NOT NULL, 15 | "signCount" INTEGER NOT NULL DEFAULT 0, 16 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | "updatedAt" DATETIME NOT NULL, 18 | CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 19 | ); 20 | INSERT INTO "new_Credential" ("createdAt", "externalId", "id", "name", "publicKey", "signCount", "updatedAt", "userId") SELECT "createdAt", "externalId", "id", "name", "publicKey", "signCount", "updatedAt", "userId" FROM "Credential"; 21 | DROP TABLE "Credential"; 22 | ALTER TABLE "new_Credential" RENAME TO "Credential"; 23 | CREATE UNIQUE INDEX "Credential_externalId_key" ON "Credential"("externalId"); 24 | CREATE UNIQUE INDEX "Credential_publicKey_key" ON "Credential"("publicKey"); 25 | CREATE INDEX "Credential_externalId_idx" ON "Credential"("externalId"); 26 | PRAGMA foreign_key_check; 27 | PRAGMA foreign_keys=ON; 28 | -------------------------------------------------------------------------------- /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 = "sqlite" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | email String @unique 16 | username String @unique 17 | credentials Credential[] 18 | 19 | createdAt DateTime @default(now()) 20 | updatedAt DateTime @updatedAt 21 | } 22 | 23 | model Credential { 24 | id Int @id @default(autoincrement()) 25 | user User @relation(fields: [userId], references: [id]) 26 | userId Int 27 | 28 | name String? 29 | externalId String @unique 30 | publicKey Bytes @unique 31 | signCount Int @default(0) 32 | 33 | createdAt DateTime @default(now()) 34 | updatedAt DateTime @updatedAt 35 | 36 | @@index([externalId]) 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------