Fullstack Project using Next.js, Graphql and Nextauth

Project Resource

Build and Deploy a Full Stack Next.js 13 Application | React, Next JS 13, TypeScript, Tailwind CSS
By JavaScript Mastery
Youtube Video Link:https://www.youtube.com/watch?v=986hztrfaSQ
Thanks for amazing tutorial :)

Technical Stack

  • Frontend: React, tailwindcss
  • Authentication: NextAuth
  • Data storage: grafbase
  • Image storage: cloudinary

Main Module

  • Frontend UI(components)
  • Authentication(api/auth, lib/session.ts)
  • Data storage(grafbase,graphql,lib/action.ts,api/cloudinary)

system architecture

Frontend UI

Tech Stack: NextAuth, GCP, Nextjs API

project folder

  • app: contains api and frontend route
    • app/api: next api, can be called while app is running, in this project used as auth and image upload.
    • app/route/page.tsx: nextjs routing system, each page is a route, could be accessed by /route
  • components: reusable components, such as button, navigation bar, etc.
  • public: static resources, such as img, svg icon, etc. can be accessed by / from anywhere in the project
  • node modules: npm package storage
  • .next: store build files and cache
  • .env: environment variables
  • common.types.ts: export types for typescript check

Authentication

Tech Stack: NextAuth, GCP

Auth Process

/lib/seesion.ts

init authOptions and getServerSession

import { getServerSession } from "next-auth";
import { NextAuthOptions,User } from "next-auth";
import { AdapterUser } from "next-auth/adapters";
import GoogleProvider from 'next-auth/providers/google'
import jsonwebtoken from 'jsonwebtoken'
import { JWT } from "next-auth/jwt";
import { SessionInterface, UserProfile } from "@/common.types";
import { createUser, getUser } from "./actions";

export const authOptions: NextAuthOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret:process.env.GOOGLE_CLIENT_SECRET!
    })
  ],
  jwt:{
    encode:({secret, token})=>{
      const encodedToken = jsonwebtoken.sign({
        ...token,
        iss:'grafbase',
        exp: Math.floor(Date.now()/1000) + 60 * 60
      }, secret)
      return encodedToken
    },
    decode: async ({secret, token})=>{
      const decodedToken = jsonwebtoken.verify(token!, secret) as JWT
      return decodedToken
    }
  },
  theme:{
    colorScheme:'light',
    logo:'./logo.png'
  },
  callbacks:{
    async session({session}) {
      const email = session?.user?.email as string

      try {
        const data = await getUser(email) as {user?: UserProfile}
        const newSession = {
          ...session,
          user: {
            ...session.user,
            ...data?.user
          }
        }
        return newSession
      } catch(error) {
        console.log('Error retrieving user data',error)
        return session
      }

    },
    async signIn({user}: {user: AdapterUser | User}){
      try{
        //get the user if they exist
        const userExists = await getUser(user?.email as string) as {user?: UserProfile}
        // if they do not exist, create them
        if(!userExists.user) {
          await createUser(
            user.name as string, 
            user.email as string, 
            user.image as string
            )
        }
        return true
      } catch (error: any) {
        console.log(error)
        return false
      }
    }
  }
}

export async function getCurrentUser() {
  const session = await getServerSession(authOptions) as SessionInterface

  return session
}

/app/api/auth/[…nextauth]/route.ts

use authOptions, generate auth handler

import NextAuth from "next-auth/next";
import { authOptions } from "@/lib/session";

const handler = NextAuth(authOptions)

export {handler as GET, handler as POST}

/app/api/auth/token/route.ts

return token

import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";

const secret = process.env.NEXTAUTH_SECRET

export async function GET(req: NextRequest) {
  const token = await getToken({req, secret, raw: true})

  return NextResponse.json({token},{status:200})
}

/app/page.tsx

signIn page

"use client";
import { useState,useEffect } from "react";
import {getProviders, signIn} from 'next-auth/react'
import Button from "./Button";
type Provider = {
  id: string;
  name: string;
  type: string;
  signinUrl: string;
  callbackUrl: string;
  signinUrlParams?: Record<string, string> | undefined;
};

type Providers = Record<string, Provider>;
const AuthProviders = () => {
  const [providers, setProviders] = useState<Providers | null>(null)
  useEffect(()=>{
    const fetchProviders = async ()=>{
      const res = await getProviders();
      setProviders(res);
      console.log(res)
    }
    fetchProviders()
  },[])
  if(providers){
    return (
      <div>
        {Object.values(providers).map(
          (provider: Provider, i)=> (
            <Button key={i} title="Sign In" handleClick={()=>signIn(provider?.id)} />
          )
        )}
      </div>
    )
  }
}

export default AuthProviders

Image Storage

Tech Stack: Cloudinary, Nextjs API

Image Storage

/app/api/upload/route.ts

import { NextResponse } from "next/server"
import {v2 as cloudinary} from 'cloudinary'

cloudinary.config({ 
  cloud_name: process.env.CLOUDINARY_NAME, 
  api_key: process.env.CLOUDINARY_KEY, 
  api_secret: process.env.CLOUDINARY_SECRET 
});


export async function POST(request:Request) {
    const {path} = await request.json()
    if(!path){
      return NextResponse.json(
        {message: "Image path is required"},
        {status: 400}
      )
    }

    try {
      const options = {
        use_filename: true,
        unique_filename: false,
        overwrite: true,
        transformation: [{width: 1000, height: 752, crop:'scale'}]
      }
      const result = await cloudinary.uploader.upload(path,options)
      return NextResponse.json(result,{status:200})
    } catch (error) {
        return NextResponse.json(
        {message: error},
        {status: 500}
      )
    }
}
"use client"
import { ProjectInterface, SessionInterface } from "@/common.types"
import { ChangeEvent, useState } from "react"
import Image from "next/image"
import FormField from "./FormField"
import { categoryFilters } from "@/constants"
import CustomMenu from "./CustomMenu"
import Button from "./Button"
import { createNewProject, fetchToken, updateProject } from "@/lib/actions"
import { useRouter } from "next/navigation"
type Props = {
  type: string,
  session: SessionInterface,
  project?: ProjectInterface
}
const ProjectForm = ( {type, session, project}: Props ) => {
  const router = useRouter()
  const handleFormSubmit = async  (e: React.FormEvent) => {
    e.preventDefault()
    setIsSubmitting(true)
    const {token} = await fetchToken()
    try {
      if(type==='create'){
        // create new project
        await createNewProject(form, session?.user?.id, token)
        router.push('/')
      }
      if(type === 'edit'){
        await updateProject(form, project?.id as string, token)
        router.push('/')
      }
    } catch (error) {
      console.log(error)
    } finally {
      setIsSubmitting(false)
    }
  }


  const handleChangeImage = (e: ChangeEvent<HTMLInputElement>) => {
    e.preventDefault()
    const file = e.target.files?.[0]
    if(!file) return
    if(!file.type.includes('image')) {
      return alert("Please upload an image file")
    }
    const reader = new FileReader()
    reader.readAsDataURL(file)
    reader.onload = ()=>{
      const result = reader.result as string
      handleStateChange('image',result)
    }
  }

  return (
    <form onSubmit={handleFormSubmit} className="flexStart form">
        <label htmlFor="poster" className="flexCenter form_image-label">
          {!form.image && 'Choose a poster for your project'}
        </label>
        <input id="image" type="file" accept="image/*" required={type==='create'} className="form_image-input" onChange={handleChangeImage}/>
        {form.image && (
          <Image src={form?.image} className="sm:p-10 object-contain z-20" alt="Project poster" fill/>
        )}
      </div>
      <div className="flexStart w-full">
        <Button 
          title={isSubmitting ? `${type==='create'?'Creating':'Editing'}`
          :`${type==='create'?'Create':'Edit'}`} 
          type="submit" leftIcon={isSubmitting?"":"/plus.svg"} submitting={isSubmitting}
        />
      </div>
    </form>
  )
}

export default ProjectForm

/lib/action.ts/uploadImage

export const uploadImage = async (imagePath: string) => {
  try {
    const response = await fetch(`${serverUrl}/api/upload`,{
      method: 'POST',
      body: JSON.stringify({path: imagePath})
    })
    return response.json()
  } catch (error) {
    throw error
  }
}

Data Storage (Graphql)

Tech Stack: Grafbase, graphql-request

Data Storage

  • Graphql Client

make graphql query, connect grafbase

/lib/action.ts

import { ProjectForm } from "@/common.types";
import { allProjectsQuery, createProjectMutation, createUserMutation, deleteProjectMutation, getProjectByIdQuery, getProjectsOfUserQuery, getUserQuery, projectsQuery, updateProjectMutation } from "@/graphql";
import { GraphQLClient } from "graphql-request";

const isProduction = process.env.NODE_ENV === 'production';
const apiUrl = isProduction ? process.env.NEXT_PUBLIC_GRAFBASE_API_URL || '' : 'http://127.0.0.1:4000/graphql'
const apiKey = isProduction ? process.env.NEXT_PUBLIC_GRAFBASE_API_KEY || '' : 'letmein'
const serverUrl = isProduction ? process.env.NEXT_PUBLIC_SERVER_URL : 'http://localhost:3000'
const client = new GraphQLClient(apiUrl)
const makeGraphQLRequest = async (query: string, variables={}) => {
  try{
    //client request....
    return await client.request(query, variables)
  } catch (error) {
    throw error
  }
}

export const getUser = (email: string) => {
  client.setHeader('x-api-key',apiKey)
  return makeGraphQLRequest(getUserQuery,{email})
}

export const createUser = (name: string, email: string, avatarUrl: string) => {
  client.setHeader('x-api-key',apiKey)
  const variables = {
    input: {
      name, email, avatarUrl
    }
  }
  return makeGraphQLRequest(createUserMutation, variables)
}

export const fetchToken = async ()=> {
  try {
    const response = await fetch(`${serverUrl}/api/auth/token`)
    return response.json()
  } catch(error) {
    throw error
  }
}

export const uploadImage = async (imagePath: string) => {
  try {
    const response = await fetch(`${serverUrl}/api/upload`,{
      method: 'POST',
      body: JSON.stringify({path: imagePath})
    })
    return response.json()
  } catch (error) {
    throw error
  }
}

export const createNewProject = async (form: ProjectForm, creatorId: string, token: string) => {
  const imageUrl = await uploadImage(form.image)
  if(imageUrl.url) {
    client.setHeader("Authorization",`Bearer ${token}`)
    const variables = {
      input: {
        ...form,
        image: imageUrl.url,
        createdBy: {
          link: creatorId
        }
      }
    }
    return makeGraphQLRequest(createProjectMutation, variables)
  }
}


export const fetchAllProjects = (category?: string | null, endcursor?: string | null) => {
  client.setHeader("x-api-key", apiKey);
  if(!category){
    return makeGraphQLRequest(allProjectsQuery)
  } else{
    return makeGraphQLRequest(projectsQuery,{category,endcursor});
  }
};


export const getProjectDetails = (id:string) => {
  client.setHeader('x-api-key',apiKey)
  return makeGraphQLRequest(getProjectByIdQuery,{id})
}


export const getUserProjects = (id:string, last?: number) => {
  client.setHeader('x-api-key',apiKey)
  return makeGraphQLRequest(getProjectsOfUserQuery,{id, last})
}

export const deleteProject = (id:string, token: string) => {
  client.setHeader("Authorization",`Bearer ${token}`)
  return makeGraphQLRequest(deleteProjectMutation,{id})
}

export const updateProject = async (form: ProjectForm, projectId:string, token: string) => {
  function isBase64DataURL(value: string) {
    const base64Regex = /^data:image\/[a-z]+;base64,/;
    return base64Regex.test(value);
  }
  let updatedForm = {...form}
  const isUploadingNewImage = isBase64DataURL(form.image)
  if(isUploadingNewImage) {
    const imageUrl = await uploadImage(form.image)
    if(imageUrl.url) {
      updatedForm = {
        ...form,
        image: imageUrl.url
      }
    }
  }
  const variables = {
    id: projectId,
    input: updatedForm
  }
  client.setHeader("Authorization",`Bearer ${token}`)
  return makeGraphQLRequest(updateProjectMutation,variables)
}
  • graphql query

store graphql query

/graphql/index.ts

export const createProjectMutation = `
 mutation CreateProject($input: ProjectCreateInput!) {
  projectCreate(input: $input) {
   project {
    id
    title
    description
    createdBy {
     email
     name
    }
   }
  }
 }
`;

export const updateProjectMutation = `
 mutation UpdateProject($id: ID!, $input: ProjectUpdateInput!) {
  projectUpdate(by: { id: $id }, input: $input) {
   project {
    id
    title
    description
    createdBy {
     email
     name
    }
   }
  }
 }
`;

export const deleteProjectMutation = `
  mutation DeleteProject($id: ID!) {
    projectDelete(by: { id: $id }) {
      deletedId
    }
  }
`;
      
export const createUserMutation = `
 mutation CreateUser($input: UserCreateInput!) {
  userCreate(input: $input) {
   user {
    name
    email
    avatarUrl
    description
    githubUrl
    linkedInUrl
    id
   }
  }
 }
`;
export const allProjectsQuery = `
  query getProjects {
    projectSearch(first: 1) {
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
      edges {
        node {
          title
          githubUrl
          description
          liveSiteUrl
          id
          image
          category
          createdBy {
            id
            email
            name
            avatarUrl
          }
        }
      }
    }
  }
`;
export const projectsQuery = `
  query getProjects($category: String, $endcursor: String) {
    projectSearch(first: 8, after: $endcursor, filter: {category: {eq: $category}}) {
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
      edges {
        node {
          title
          githubUrl
          description
          liveSiteUrl
          id
          image
          category
          createdBy {
            id
            email
            name
            avatarUrl
          }
        }
      }
    }
  }
`;


export const getProjectByIdQuery = `
  query GetProjectById($id: ID!) {
    project(by: { id: $id }) {
      id
      title
      description
      image
      liveSiteUrl
      githubUrl
      category
      createdBy {
        id
        name
        email
        avatarUrl
      }
    }
  }
`;

export const getUserQuery = `
  query GetUser($email: String!) {
    user(by: { email: $email }) {
      id
      name
      email
      avatarUrl
      description
      githubUrl
      linkedInUrl
    }
  }
`;
      
export const getProjectsOfUserQuery = `
  query getUserProjects($id: ID!, $last: Int = 4) {
    user(by: { id: $id }) {
      id
      name
      email
      description
      avatarUrl
      githubUrl
      linkedInUrl
      projects(last: $last) {
        edges {
          node {
            id
            title
            image
          }
        }
      }
    }
  }
`;
  • grafbase

init grafbase and data model

/grafbase/grafbase.config.ts

import { g, auth, config } from '@grafbase/sdk'


// Welcome to Grafbase!
// Define your data models, integrate auth, permission rules, custom resolvers, search, and more with Grafbase.
// Integrate Auth
// https://grafbase.com/docs/auth
//
// const authProvider = auth.OpenIDConnect({
//   issuer: process.env.ISSUER_URL ?? ''
// })
//
// Define Data Models
// https://grafbase.com/docs/database

// @ts-ignore
const User = g.model('User',{
  name: g.string().length({ min: 2, max: 20}),
  email: g.string().unique(),
  avatarUrl: g.url(),
  description: g.string().optional(),
  githubUrl: g.url().optional(),
  linkedInUrl: g.url().optional(),
  projects: g.relation(()=>Project).list().optional(),
}).auth((rules)=>{
  rules.public().read()
})

// @ts-ignore
const Project = g.model('Project',{
  title: g.string().length({min:3}),
  description: g.string(),
  image: g.url(),
  liveSiteUrl:g.url(),
  githubUrl: g.url(),
  category: g.string().search(),
  createdBy: g.relation(()=>User)
}).auth((rules)=>{
  rules.public().read(),
  rules.private().create().delete().update();
})

const jwt = auth.JWT({
  issuer: 'grafbase',
  secret: g.env('NEXTAUTH_SECRET')
})


export default config({
  schema: g,
  // Integrate Auth
  // https://grafbase.com/docs/auth
  auth: {
    providers: [jwt],
    rules: (rules) => {
      rules.private()
    }
  }
})

/grafbase/.env

# KEY=VALUE
NEXTAUTH_SECRET = abc

An example for data fetch at frontend

/app/page.tsx

const Home = async ({searchParams: {category, endCursor}}: Props) => {
  const data = await fetchAllProjects(category,endCursor) as ProjectSearch
  const projectsToDisplay = data?.projectSearch?.edges || []
  return (
    <div>
    {projectsToDisplay}
    </div>
  )
}

// submit and store a new project(need token)
const handleFormSubmit = async  (e: React.FormEvent) => {
    e.preventDefault()
    setIsSubmitting(true)
    const {token} = await fetchToken()
    try {
      if(type==='create'){
        // create new project
        await createNewProject(form, session?.user?.id, token)
        router.push('/')
      }
      if(type === 'edit'){
        await updateProject(form, project?.id as string, token)
        router.push('/')
      }
    } catch (error) {
      console.log(error)
    } finally {
      setIsSubmitting(false)
    }
  }