- What is T3 Stack?
- Why Lucia-auth?
- How to add lucia-auth to NextJS >= 13 (App Router)
- Optional: How to setup Prisma on NixOS
- Installing lucia-auth dependencies
- Editing the schema
- Adding the basic lucia-auth functionality
- Adding lucia to TRPC - TRPCReactProvider
- Adding lucia-auth to TRPC - createTRPCContext
- Adding lucia-auth to TRPC - protectedProcedure
- Adding lucia-auth to TRPC - the auth router
- Conclusion - how to use lucia-auth with TRPC
Developing web applications is my daily bread and butter. Over the past year or so, I really came to love T3-Stack. This post covers the setup of lucia-auth with a create-t3-app
.
What is T3 Stack?
For the uninitiated, T3 stack is the name of a project starter for NextJS. Originally started by Theo GG, T3 focuses on easing the setup of a new project with some very nice packages pre-configured.
When you start your T3 project, you walk through an installation wizard. I usually take all the goodness: Tailwind, TRPC, app router, yes please!
Depending on your needs, you can start with a database with Prisma or Drizzle ORM and even have authentication with NextAuth pre-configured. All super great, but I’m not such a big fan of NextAuth.
The reason is that most of my projects do not use the OAuth Providers from Discord, Google or GitHub. After all, we follow the LandChad ethos here and outsourcing the user authentication and creating this important dependency is just not something I am willing to accept. NextAuth offers a username/password credentials authentication flow, but still…
Why Lucia-auth?
What else? Well JoshTriedCoding did a video on an authentication library that according to him has the same, very good level of abstraction as shadcn. I fucking love ShadCN. Building cool user interfaces has never been so easy for me. Naturally, Josh had me immediately hooked and I needed to try out this gem he talked about: Lucia-auth.
The documentation of lucia-auth is also not too bad. They have examples for many different variations of authentication. However, they don’t have an example for using it with TRPC and having procedures for login, signup and logout.
This is exactly what I’ll cover in the rest of the post. We’ll setup a basic T3 stack app and then add lucia-auth to it. Ever step will be shown in detail, and you should be able to reproduce.
How to add lucia-auth to NextJS >= 13 (App Router)
Let’s get started. I will use bun
as my package manager and runtime. You don’t have to; npm
, yarn
, pnpm
will also do just fine. First up, we open the terminal, navigate to a directory that suits as a parent for our project and then execute:
bun create t3-app@latest
Here is how I answered the questions in the wizard:
Then, T3-Stack informs us about the next steps:
Next steps:
cd lucia-auth-trpc-t3
bun install
bun run db:push
bun run dev
git commit -m "initial commit"
Optional: How to setup Prisma on NixOS
Now, since I’m on NixOS (as you may have gathered from My Linux Odyssey), I have to add a dev shell so I can use Prisma. I use the following setup in most of my typescript projects.
As a prerequisite to this step, you need to have devenv setup. I won’t go into this here, maybe at a later post in time.
First, we create our shell.nix
that houses the project specific packages and configs:
{
pkgs ? import <nixpkgs> { },
}:
pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_20
nodePackages_latest.prisma
prisma-engines
];
shellHook = ''
export PRISMA_QUERY_ENGINE_LIBRARY=${pkgs.prisma-engines}/lib/libquery_engine.node
export PRISMA_QUERY_ENGINE_BINARY=${pkgs.prisma-engines}/bin/query-engine
export PRISMA_SCHEMA_ENGINE_BINARY=${pkgs.prisma-engines}/bin/schema-engine
'';
}
The environment variables are used for our prisma
commands to detect the right binary.
Next, we create a flake.nix
alongside the shell.nix
in our project root.
{
description = "Run 'nix develop' to have a dev shell that has everything this project needs";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = import ./shell.nix { inherit pkgs; };
}
);
}
This is a generic flake that imports our shell.nix
and installs it for the right system.
The final cherry on top is a .envrc
file in the project root with this content:
if nix flake show &> /dev/null; then
use flake
else
use nix
fi
So, if the developer uses flakes, we use the flake setup, otherwise we just use the vanilla shell.nix
.
Make sure these new files are checked into git
.
Finally, in the project root run direnv allow
to install everything. You should then be able to migrate the Prisma schema and database with prisma migrate dev
.
Installing lucia-auth dependencies
With the basic project setup out of the way, we can install lucia-auth. So we follow the basic “Getting started”.
- Install lucia
bun add lucia
- Add the Prisma adapter for lucia
bun add @lucia-auth/adapter-prisma
. (If you went with Drizzle in the setup, of course this needs to be the Drizzle ORM adapter)
Editing the schema
Since we are going for username/password credentials authentication, we have to store users and their sessions in our database. We can achieve this like so:
model User {
id String @id
username String @unique
password String
sessions Session[]
}
model Session {
id String @id
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
}
Migrate the schema with prisma migrate dev
(or bun prisma migrate dev
if you skipped the NixOS step). Give it a meaningful name like “add user and session”.
Adding the basic lucia-auth functionality
Create the file src/server/auth.ts
and add the following:
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import { PrismaClient } from "@prisma/client";
import { Lucia } from "lucia";
const client = new PrismaClient();
const adapter = new PrismaAdapter(client.session, client.user);
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
// set to `true` when using HTTPS
secure: process.env.NODE_ENV === "production",
},
},
getUserAttributes: (attributes) => {
return {
// attributes has the type of DatabaseUserAttributes
username: attributes.username,
};
},
});
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
// simply pick some properties from our Prisma User schema
type DatabaseUserAttributes = Pick<User, "username">;
This is our basic setup for lucia. We initialize it with the Prisma adapter, and provide it with some attributes of our user.
However, there is one more function I add to this file: the validateRequest()
. It can be called on the server side to check the cookies of the user. We could add it at the top of a page function, or - as we’ll see in the next section - add it to the TRPC context.
export const validateRequest = cache(
async (): Promise<
{ user: User; session: Session } | { user: null; session: null }
> => {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) {
return { user: null, session: null };
}
const result = await lucia.validateSession(sessionId);
// next.js throws when you attempt to set cookies while rendering the page
try {
if (result.session && result.session.fresh) {
const sessionCookie = lucia.createSessionCookie(result.session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
}
if (!result.session) {
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
}
} catch {}
return result;
},
);
Adding lucia to TRPC - TRPCReactProvider
In the lucia docs they now go for server actions. With this tutorial though, we will now add the authentication to TRPC. This will allow us to have protectedProcedures
that automatically check if a user is logged in. Furthermore, we can add the user and session to the TRPC context. With this, we can always use the logged in userID to get for example only a specific profile from the database.
First up, we have to modify the default TRPC setup to stop using the unstable_httpBatchStreamLink
. This is the most critical step that had me struggling for a long time. The reason is that with unstable_httpBatchStreamLink
the server responds immediately after request and stream the response. Great and all, but then we can no longer set cookies - which we need for auth!
In the src/trpc/react.tsx
the TRPCReactProvider should look as follows:
"use client";
import { loggerLink, httpBatchLink } from "@trpc/client";
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
// 💡 use this!
httpBatchLink({
// instead of this
// unstable_httpBatchStreamLink({
transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc",
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
Adding lucia-auth to TRPC - createTRPCContext
In the src/server/api/trpc.ts
the TRPCContext is created. The Prisma adapter is added to the context for example. Let’s add the user and session to the context!
export const createTRPCContext = async (opts: { headers: Headers }) => {
const { user, session } = await validateRequest();
return {
db,
user,
session,
...opts,
};
};
That was easy! Now we can access the user and session in every procedure with ctx.user
and ctx.session
. Nice.
Adding lucia-auth to TRPC - protectedProcedure
Since our app features authentication, there are probably some things we only want to allow authenticated users to perform. Luckily TRPC provides a convenient way to re-use this logic.
We simply create a protectedProcedure
in src/server/api/trpc.ts
:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user || !ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// ensure that session and user are non-nullable for typescript
return next({
ctx: { session: { ...ctx.session }, user: { ...ctx.user } },
});
});
Fantastic. Now we can use this in our routers to automatically protect certain routes.
Adding lucia-auth to TRPC - the auth router
There is one last thing we actually need to write: a TRPCRouter for our authentication logic.
We’ll keep this simple and create these routers:
me
- returns the authenticated User from the databaselogin
- authenticate with an existing Usersignup
- create a User in the databaselogout
- kill the session for the authenticated User
For the login, signup and logout I simply adopt the example “server action” code from the lucia-auth docs. There is a lot of code below, but if you are reading this far, it may be very instructional.
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import { z } from "zod";
import { verify, hash } from "@node-rs/argon2";
import { TRPCError } from "@trpc/server";
import { lucia } from "~/server/auth";
import { cookies } from "next/headers";
const LoginSchema = z.object({
username: z.string(),
password: z.string(),
});
export const authRouter = createTRPCRouter({
me: protectedProcedure.query(({ ctx }) => {
return ctx.db.user.findFirstOrThrow({ where: { id: ctx.user.id } });
}),
login: publicProcedure.input(LoginSchema).mutation(async ({ ctx, input }) => {
const existingUser = await ctx.db.user.findFirst({
where: { username: input.username },
});
if (!existingUser) throw new TRPCError({ code: "UNAUTHORIZED" });
const validPassword = await verify(existingUser.password, input.password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
if (!validPassword) throw new TRPCError({ code: "UNAUTHORIZED" });
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
return true;
}),
signup: publicProcedure
.input(LoginSchema)
.mutation(async ({ ctx, input }) => {
const existingUser = await ctx.db.user.findFirst({
where: { username: input.username },
});
if (existingUser)
throw new TRPCError({
code: "FORBIDDEN",
message: "Username already taken",
});
const hashedPassword = await hash(input.password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
const { id } = await ctx.db.user.create({
data: {
username: input.username,
password: hashedPassword,
},
});
const session = await lucia.createSession(id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
return true;
}),
logout: protectedProcedure.mutation(async ({ ctx }) => {
await lucia.invalidateSession(ctx.session.id);
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
}),
});
Conclusion - how to use lucia-auth with TRPC
Finally we are done! Well, at least for the basics. Lucia informs us to implement a CSRF token if there are no server actions used. So that’s a TODO. Then we also need our front-end components, like the forms and pages.
This post is already way too long, so I won’t go into it here. But you will find great resources elsewhere.
Just keep in mind that now we can use our auth router on the server and client by simply using the right import:
// Import this on server pages and in server logic
import { api } from "~/trpc/server";
const me = await api.auth.me()
// Import this on client components (where "use client" is used)
import { api } from "~/trpc/react"
const { data: me } = await api.auth.me.useQuery()
Thanks for following this lengthy guide. I hope you learned something. Make sure to follow my RSS feed to always stay up to date with new posts.