Search code examples
typescriptnext.jsnext.js13react-typescript

How to type annotate searchParams in Next.js 13?


In my Next.js 13 project, I have a login form as shown below:

"use client";

import * as React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { useSearchParams } from "next/navigation";
import Link from "next/link";

import { cn } from "@/lib/util";
import { userAuthSchema } from "@/lib/validations/auth";
import { buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Icons } from "@/components/icons";
import { loginUser } from "@/lib/login-user";

export type FormData = z.infer<typeof userAuthSchema>;

export function LoginForm() {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<FormData>({ resolver: zodResolver(userAuthSchema) });

  const [isLoading, setIsLoading] = React.useState<boolean>(false);
  const [error, setError] = React.useState<string | null>(null);
  const [isGoogleLoading, setIsGoogleLoading] = React.useState<boolean>(false);

  const searchParams = useSearchParams();

  async function onSubmit(submittedData: FormData) {
    await loginUser({
      submittedData,
      setError,
      setIsLoading,
      reset,
      searchParams,
    });
  }

  return (
    <div className="grid gap-6">
      {error && (
        <p className="text-sm text-red-500 animate-in fade-in-0 slide-in-from-left-1">
          {error}
        </p>
      )}
      <form onSubmit={handleSubmit(onSubmit)}>
        <div className="grid gap-6">
          <div className="grid gap-1">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              placeholder="[email protected]"
              autoCapitalize="none"
              autoComplete="email"
              autoCorrect="off"
              disabled={isLoading || isGoogleLoading}
              {...register("email")}
            />
            {errors.email && (
              <p className="px-1 text-xs text-red-600 animate-in fade-in-0 slide-in-from-left-1">
                {errors.email.message}
              </p>
            )}
          </div>
          <div className="grid gap-1">
            <div className="flex items-center justify-between">
              <Label htmlFor="password">Password</Label>
              <Link
                href="/request-password-reset"
                className="text-sm font-medium text-sky-700 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2 active:text-sky-400"
              >
                Forgot password?
              </Link>
            </div>
            <Input
              id="password"
              type="password"
              autoCapitalize="none"
              autoComplete="off"
              autoCorrect="off"
              disabled={isLoading || isGoogleLoading}
              {...register("password")}
            />
            {errors.password && (
              <p className="px-1 text-xs text-red-600 animate-in fade-in-0 slide-in-from-left-1">
                {errors.password.message}
              </p>
            )}
          </div>
          <button className={cn(buttonVariants())} disabled={isLoading}>
            {isLoading && (
              <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
            )}
            Login
          </button>
        </div>
      </form>
      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <span className="w-full border-t border-neutral-200" />
        </div>
        <div className="relative flex justify-center text-xs uppercase">
          <span className="bg-neutral-100 px-2">Or</span>
        </div>
      </div>
      <button
        type="button"
        className={cn(buttonVariants({ variant: "outline" }), "w-full")}
        onClick={() => {
          setIsGoogleLoading(true);
          signIn("google", {
            callbackUrl: searchParams?.get("from") || "/blog",
          });
        }}
        disabled={isLoading || isGoogleLoading}
      >
        {isGoogleLoading ? (
          <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
        ) : (
          <Icons.google className="mr-2 h-6 w-6" />
        )}{" "}
        Login with Google
      </button>
    </div>
  );
}

As you can see, searchParams is one of the arguments passed to the loginUser() function inside the onSubmit handler. Here's the relevant portion of the code:

  async function onSubmit(submittedData: FormData) {
    await loginUser({
      submittedData,
      setError,
      setIsLoading,
      reset,
      searchParams,
    });
  }

And here's the loginUser() function:

import type { FormData } from "@/components/auth/register-form";
import { signIn } from "next-auth/react";

const RESPONSE_MESSAGES = {
  USER_NOT_FOUND: "User not found",
  INVALID_PASSWORD: "Invalid password",
  LOGIN_SUCCESSFUL: "Logged in successfully",
  LOGIN_FAILURE: "Login failed. Please try again.",
  EMAIL_NOT_VERIFIED: "Please verify your email",
};

interface LoginUserProps {
  submittedData: FormData;
  setError: React.Dispatch<React.SetStateAction<string | null>>;
  setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
  reset: () => void;
  searchParams: any;
}

export async function loginUser({
  submittedData,
  setError,
  setIsLoading,
  reset,
  searchParams,
}: LoginUserProps) {
  setIsLoading(true);
  setError(null);

  try {
    const response = await fetch("/api/login", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(submittedData),
    });

    const responseData = await response.json();

    if (!response.ok) {
      throw new Error(RESPONSE_MESSAGES.LOGIN_FAILURE);
    }

    if (responseData.message === RESPONSE_MESSAGES.USER_NOT_FOUND) {
      setError(RESPONSE_MESSAGES.USER_NOT_FOUND);
      return;
    }

    if (responseData.message === RESPONSE_MESSAGES.EMAIL_NOT_VERIFIED) {
      setError(RESPONSE_MESSAGES.EMAIL_NOT_VERIFIED);
      return;
    }

    if (responseData.message === RESPONSE_MESSAGES.INVALID_PASSWORD) {
      setError(RESPONSE_MESSAGES.INVALID_PASSWORD);
      return;
    }

    // Use signIn just to log in, and use router.push for redirection.
    if (responseData.message === RESPONSE_MESSAGES.LOGIN_SUCCESSFUL) {
      reset(); // Reset the form state

      // Sign-in and let it handle the redirection
      signIn("credentials", {
        ...submittedData,
        callbackUrl: searchParams?.get("from") || "/blog",
      });
    }
  } catch (error) {
    setError((error as Error).message);
  } finally {
    setIsLoading(false);
  }
}

As you can see, I have currently typed searchParams as any. Of course, I don't want to do that. Here is the relevant portion of the code:

interface LoginUserProps {
  submittedData: FormData;
  setError: React.Dispatch<React.SetStateAction<string | null>>;
  setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
  reset: () => void;
  searchParams: any;
}

Can someone tell me what would be the type of searchParams? Thanks...


Solution

  • The return type of useSearchParmas is ReadonlyURLSearchParams in the App dir and ReadonlyURLSearchParams | null in the pages directory.

    The ReadonlyURLSearchParams is imported from next/navigation

    import { ReadonlyURLSearchParams } from "next/navigation"
    // ...
    const searchParams = useSearchParams();
    // ?^ searchParams: ReadonlyURLSearchParams