iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐎

I Built a Poll Service with Comment Functionality

に公開

Introduction

There are many issues in the world that must be settled.

  • Kinoko or Takenoko (Mushroom or Bamboo shoot)
  • Windows or Mac
  • Android or iPhone

And so on...
I have created a place to serve as the main battlefield for such debates.
https://www.poll-gate.com/

Features

  • Creating polls
  • Voting in polls
  • Commenting on polls you've voted in

*Login is required for all of these

Technologies Used

Stack

  • Hosting: Vercel
  • DB: PlanetScale
  • Domain: Cloudflare

Frameworks, etc.

  • NextJS (App Router)
  • NextAuth
  • Prisma
  • react-hook-form
  • MUI

Things I Used for the First Time

PlanetScale

It's really great.
Can I really use 5GB? I can't even use it all.
I haven't made any adjustments to save on reads yet, so I'll monitor the situation and adjust accordingly in the future.

Prisma

Since it was almost my first time using an RDB, I'm not sure which would have been better: learning SQL or using Prisma. When updating schema.prisma, you need to run prisma generate, etc. I didn't realize it wouldn't work properly while debugging was running and that I had to stop it before running prisma generate, so I lost quite a bit of time here.
It's really great that the retrieved data comes with types!

react-hook-form

It's really great.
At first, it looked a bit confusing, but once I used it, it was simple.
Basically, if you just throw it into the render of the Controller and pass the field like {...field}, it works like a charm.

<Controller
  name="commentText"
  control={control}
  rules={validationRules.commentText}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      placeholder="コメント"
      fullWidth
      multiline
      error={fieldState.invalid}
      helperText={fieldState.error?.message}
    />
  )}
/>

Validation can also be defined as follows and loaded into the rules of the Controller, and it will handle it automatically.

const validationRules = {
  commentText: {
    required: "コメントを入力してください",
    minLength: { value: 1, message: "1文字以上入力してください" },
  },
};

The execution status during form submission can also be easily retrieved with formState.isSubmitting!

const { control, handleSubmit, formState } = useForm<Inputs>();
...
  <LoadingButton
    type="submit"
    variant="contained"
    color="secondary"
    loading={formState.isSubmitting}
    disableElevation
    sx={{ fontWeight: "bold" }}
  >
    作成
  </LoadingButton>
 ...

Next.js Features

Server Actions

I implemented the application primarily with SSR using App Router and utilized Server Actions for data fetching as well. Only the posting form components are running on the client side. It was very easy since it can be done without an API.

Client side
const onSubmit: SubmitHandler<Inputs> = async (data: Inputs) => {
    const result = await addComment(data); // This is executed on the server side
    if ("error" in result) {
      // Error handling
    } else {
      // Success handling
    }
  };

By writing "use server", it runs on the server side.

addComment.tsx
"use server";
import { prisma } from "@/db/db";

export async function addComment(props: {
  pollId: number;
  commentText: string;
}) {
  const { pollId, commentText } = props;
  try {
    const comment = await prisma.comment.create({
     ...
    });
    return comment;
  } catch (error) {
    return { error: error };
  }
}

Parallel Routes

The screen is conditionally rendered based on the user's status: a voting screen if they haven't voted, and a result screen if they have already voted.

detail/[id]
├── @form
├── @guest
├── @result
├── @voted
└── layout.tsx

The layout.tsx handles the conditional rendering as follows.

layout.tsx
export default async function Layout({
  form,
  voted,
  guest,
  result,
  params,
}: {
  form: React.ReactNode;
  voted: React.ReactNode;
  guest: React.ReactNode;
  result: React.ReactNode;
  params: { id: string };
}) {
  const id = Number(params.id);
  if (isNaN(id)) {
    notFound();
  }
  
  const session = await getServerSession(options);
  const poll = await getPoll(id);

  const isClosed = poll.closed || (poll.deadline && poll.deadline < new Date());

  // If the poll is closed, show the result screen
  if (isClosed) {
    return <section>{result}</section>;
  }

  // If not logged in, show the guest screen
  if (!session) {
    return <section>{guest}</section>;
  }

  // If already voted, show the "voted" screen; otherwise, show the form screen
  const IsVoted = poll.votes.some((vote) => vote.authorId === session.user.uid);
  return <section>{IsVoted ? voted : form}</section>;
}

NextAuth

It's really great.
I won't write about the general usage here since it's explained clearly on the official site and other articles.
https://next-auth.js.org/getting-started/example
I'll introduce a few points that were a bit hard to understand.

Adding Information

Here is an example of adding role information.
First, declare the types somewhere.

types/next-auth.d.tsx
import type { DefaultSession } from "next-auth";

declare module "next-auth" {
  interface Session {
    user: {
      role: string;
    } & DefaultSession["user"];
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    role: string;
  }
}

Next, add the logic to store the information during the initial login.

auth.config.tsx
import type { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";

export const options: NextAuthOptions = {
  secret: process.env.NEXTAUTH_SECRET,
  session: { strategy: "jwt" },
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    jwt: async ({ token, account }) => {
      if (account) {
	// Logic for the first login
	// Add role to the token
	if (token.email == 'example.com') {
	  token.role = 'admin';
	} else {
	  token.role = 'user';
	}
      }
      return token;
    },
    session: async ({ session, token }) => {
      // If using session, also add role to the session
      session.user.role = token.role;
      return session;
    },
  },
};

With this, you can handle it in middleware or retrieve information from the session.

layout.tsx etc.
export default async function Layout({
  children
}: {
  children: React.ReactNode;
}) {  
  const session = await getServerSession(options);
  const role = session.role;  
  
  return <section>{children}</section>
}

Middleware Processing

By writing middleware logic, you can specify pages that require authentication or add processing during authentication.
The middleware should be created directly under src.
If you only need to specify pages that require authentication, you can just do the following:

middleware.tsx
export { default } from "next-auth/middleware"

export const config = { 
  matcher: [
    "/user/:path*", // If you want all pages under /user to require authentication
    "/dashboard" // Only /dashboard will require authentication. *Paths under /dashboard will not be authenticated
  ]
}

If you want to perform checks, such as whether the user has permission to access the page during authentication, do the following:
When a path specified in the matcher in config is accessed, callbacks.authorized is executed. If it returns true, access is granted; if false, it redirects to the path specified in signIn.

middleware.tsx
import { withAuth } from "next-auth/middleware";

export default withAuth(
  // function middleware(req) {
  //   // Executed if callbacks.authorized returns true
  // },
  {
    callbacks: {
      // Executed when the path matches the one specified in config.matcher
      // Return true to allow access, false to deny access
      authorized: ({ req, token }) => {
        if (req.nextUrl.pathname.startsWith("/admin")) {
	  // If accessing a path under /admin, return true (allow access) only if the role stored in token.role is 'admin'
	  const roleIsAdmin = token.role === 'admin'
          return roleIsAdmin;
        }
	// To allow access only when authenticated.
	// Without this, access to pages under /user specified in config would not be possible.
        return !!token;
      },
    },
    pages: {
      // If callbacks.authorized returns false, the user is redirected to the path specified here
      signIn: "/",
    },
  }
);

export const config = {
  matcher: ["/admin/:path*","/user/:path*"],
};

Future Plans

  • I haven't implemented pagination yet, so I want to add it (it currently can only display up to 50 items).
  • Some parts of the UI are a bit rough, so I want to improve them. As you can probably tell, I implemented it while looking at how Zenn and Reddit do things without overthinking it too much.
  • There are some areas where the UI reflects changes slowly, so I want to fix that. I'd like to use useOptimistic properly.
  • Promotion (it seems like the most important thing, but it's also the one I understand the least).

Conclusion

I came up with the idea on a whim and implemented it in about half a month while watching One Piece. Although it's a bit unpolished in some areas, I've implemented the bare minimum and released it. (I'm sure I'm not the only one who gets a lot of work done while watching One Piece.)
Speed is really important in solo development, isn't it?
If people find it useful, I intend to keep going!

References

For react-hook-form and Server Actions, the videos on this channel were very easy to understand and extremely helpful!
https://www.youtube.com/channel/UCf6AGqO98eGk11nfazociVQ/videos

Discussion