This iteration is not a complete authentication solution. It only looks at the session. NextJS recommends Iron-Session for this. As luck would have it, the latest release fully supports NextJS 13.
We need a bunch of stuff to make this work:
Let’s get started
Here is a simple login page and form. An actual login form will include a password.
// src/app/[lng]/login/page.tsx
import {
Metadata
} from "next";
import {
Form
} from "./form";
export const metadata: Metadata = {
title: "Login",
};
export default function AppRouterRedirect() {
return ( <
main >
<
h1 > Login < /h1> <
div >
<
Form / >
<
/div> < /
main >
);
}
"use client";
// src/app/[lng]/login/form.tsx
import {
useEffect,
useState
} from "react";
import {
SessionData
} from "@/session";
import {
defaultSession
} from "@/session";
export function Form() {
const [session, setSession] = useState < SessionData > (defaultSession);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch("/api/session")
.then((res) => res.json())
.then((session) => {
setSession(session);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <p > Loading... < /p>;
}
if (session.isLoggedIn) {
return ( <
>
<
p >
Logged in user: < strong > {
session.username
} < /strong> < /
p > <
LogoutButton / >
<
/>
);
}
return <LoginForm / > ;
}
function LoginForm() {
return ( <
form action = "/api/session"
method = "POST" >
<
label className = "block text-lg" >
<
span > Username < /span> <
input type = "text"
name = "username"
placeholder = ""
defaultValue = "James"
required
// for demo purposes, disabling autocomplete 1password here
// autoComplete="off"
// data-1p-ignore
/
>
<
/label> <
div >
<
input type = "submit"
value = "Login" / >
<
/div> < /
form >
);
}
function LogoutButton() {
return ( <
p >
<
a href = "/api/session?action=logout" > Logout < /a> < /
p >
);
}
Next, we should direct users to the Login page if they are not logged in. Middleware will do this.
// src/middlewares/withSession.ts
import {
NextFetchEvent,
NextRequest
} from "next/server";
import {
MiddlewareFactory
} from "./middlewareFactory";
import {
cookies
} from "next/headers";
import {
getIronSession
} from "iron-session";
import {
cookieName as i18nCookieName
} from "@/app/i18n/settings";
import {
SessionData,
sessionOptions
} from "@/session";
export const withSession: MiddlewareFactory = (next) => {
return async (request: NextRequest, _next: NextFetchEvent) => {
const session = await getIronSession < SessionData > (
cookies(),
sessionOptions
);
const lng = request.cookies.get(i18nCookieName)?.value;
if (!session.isLoggedIn && !request.nextUrl.pathname.endsWith("login")) {
const redirectTo = `/${lng}/login`;
return Response.redirect(`${request.nextUrl.origin}${redirectTo}`, 302);
}
return next(request, _next);
};
};
We can add some utility functions now. The critical export is the SessionOptions.
// src/session/index.ts
import {
SessionOptions
} from "iron-session";
export interface SessionData {
username: string;
isLoggedIn: boolean;
}
export const sessionOptions: SessionOptions = {
// should come from env
password: "complex_password_at_least_32_characters_long",
cookieName: "iron-session",
cookieOptions: {
// secure only works in `https` environments
// if your localhost is not on `https`, then use: `secure: process.env.NODE_ENV === "production"`
secure: process.env.NODE_ENV === "production",
// secure: true,
},
};
export const defaultSession: SessionData = {
username: "",
isLoggedIn: false,
};
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Now, let’s create the API endpoint that the login form calls. The actual API would make a DB or API call to authenticate. We don’t care about that step for now.
Note: this does the logout too. Again, a production solution will need to reset other resources on logout.
// src/app/api/login/route.ts
import {
NextRequest
} from "next/server";
import {
cookies
} from "next/headers";
import {
getIronSession
} from "iron-session";
import {
defaultSession,
sessionOptions
} from "@/session";
import {
redirect
} from "next/navigation";
import {
sleep,
SessionData
} from "@/session";
export async function POST(request: NextRequest) {
const session = await getIronSession < SessionData > (cookies(), sessionOptions);
const formData = await request.formData();
session.isLoggedIn = true;
session.username = (formData.get("username") as string) ?? "No username";
await session.save();
// simulate looking up the user in db
await sleep(250);
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
// not using redirect() yet: https://github.com/vercel/next.js/issues/51592#issuecomment-1810212676
return Response.redirect(`${request.nextUrl.origin}/en/`, 303);
}
export async function GET(request: NextRequest) {
const session = await getIronSession < SessionData > (cookies(), sessionOptions);
const action = new URL(request.url).searchParams.get("action");
if (action === "logout") {
session.destroy();
return redirect("/login");
}
// simulate looking up the user in db
await sleep(250);
if (session.isLoggedIn !== true) {
return Response.json(defaultSession);
}
return Response.json(session);
}
Let’s add the session information to a page. We can do this in the base Layout.
// src/app/[lng]/layout.tsx
import type {
Metadata
} from "next";
import {
Inter
} from "next/font/google";
import "./globals.css";
import {
dir
} from "i18next";
import {
PropsWithChildren
} from "react";
import {
languages
} from "@/app/i18n/settings";
import {
getIronSession
} from "iron-session";
import {
SessionData,
sessionOptions
} from "@/session";
import {
cookies
} from "next/headers";
export async function generateStaticParams() {
return languages.map((lng) => ({
lng
}));
}
const inter = Inter({
subsets: ["latin"]
});
export const metadata: Metadata = {
title: "Next Explorer",
description: "App to explore Next 13",
};
interface Props {
children: React.ReactNode;
params: {
lng: string;
};
}
export default async function RootLayout(props: PropsWithChildren < Props > ) {
const session = await getIronSession < SessionData > (cookies(), sessionOptions);
return ( <
html lang = {
props.params.lng
}
dir = {
dir(props.params.lng)
} >
<
head / >
<
body className = {
inter.className
} >
<
div > {
!!session && !!session.username && ( <
div style = {
{
display: "flex",
gap: "2rem",
alignItems: "center"
}
} >
Hello {
session.username
} <
LogoutButton / >
<
/div>
)
} <
/div> <
div > {
props.children
} < /div> < /
body > <
/html>
);
}
function LogoutButton() {
return ( <
p >
<
a href = "/api/session?action=logout" > Logout < /a> < /
p >
);
}
Now we can see who we logged in as and log out.
Now, let’s try to use session information in a client component. Here is the client component. Just add it to the second page.
"use client";
// src/app/[lng]/(protected)/second-page/ClientSessionPanel.tsx
import useSession from "@/session/use-session";
import {
useEffect
} from "react";
import {
useRouter
} from "next/navigation";
interface Props {
lng: string;
}
export default function ClientSessionPanel(props: Props) {
const {
session,
isLoading
} = useSession();
const router = useRouter();
// I don't think we need this, the middleware should be enough.
useEffect(() => {
if (!isLoading && !session.isLoggedIn) {
router.replace(`/${props.lng}/login`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading, session.isLoggedIn, router]);
if (isLoading) {
return <p > Loading... < /p>;
}
return ( <
div >
<
p >
Hello < strong > {
session.username
}! < /strong> < /
p > <
/div>
);
}
And finally, the hook used by the client component looks like this.
// src/session/use-session.ts
import useSWR from "swr";
import {
SessionData,
defaultSession
} from "@/session";
import useSWRMutation from "swr/mutation";
const sessionApiRoute = "/api/session";
async function fetchJson < JSON = unknown > (
input: RequestInfo,
init ? : RequestInit
): Promise < JSON > {
return fetch(input, {
headers: {
accept: "application/json",
"content-type": "application/json",
},
...init,
}).then((res) => res.json());
}
function doLogin(url: string, {
arg
}: {
arg: string
}) {
return fetchJson < SessionData > (url, {
method: "POST",
body: JSON.stringify({
username: arg
}),
});
}
function doLogout(url: string) {
return fetchJson < SessionData > (url, {
method: "DELETE",
});
}
export default function useSession() {
const {
data: session,
isLoading
} = useSWR(
sessionApiRoute,
fetchJson < SessionData > , {
fallbackData: defaultSession,
}
);
const {
trigger: login
} = useSWRMutation(sessionApiRoute, doLogin, {
// the login route already provides the updated information, no need to revalidate
revalidate: false,
});
const {
trigger: logout
} = useSWRMutation(sessionApiRoute, doLogout);
return {
session,
logout,
login,
isLoading
};
}