Clerk Authentication
Integrate Clerk into your Dinou v7 application to handle user authentication, route protection, and database synchronization via the Web Standards plugin system.
In Dinou v7, Clerk integrates seamlessly via the Web Standards Plugin System (dinou.config.mjs). There is no need to eject the framework: the plugin intercepts incoming requests, verifies session tokens cryptographically, exposes auth state togetContext(), and handles webhooks directly.
1. Core Concept
Clerk integration in Dinou v7 is structured around three main pillars:
Request objects inside Dinou's onRequest plugin hook.userId and auth claims are attached to Dinou's RequestContextStore, directly accessible in Server Components and Server Functions via getContext().svix and synchronize user profile events to your database by short-circuiting in the plugin.2. Installation & Environment
First, install Clerk's backend SDK, React client SDK, localization packages, and Svix (for webhook signature verification):
npm install @clerk/backend @clerk/react @clerk/localizations svixAdd your Clerk API credentials to your local .env file:
CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
CLERK_WEBHOOK_SECRET=whsec_...3. The Dinou v7 Clerk Plugin (dinou.config.mjs)
In Dinou v7, plugins hook directly into the request lifecycle using the onRequest(request, context) hook. Create a reusable Clerk plugin to authenticate incoming requests and propagate session claims to context:
// plugins/clerk.ts
import type { DinouPlugin } from "dinou";
import { createClerkClient } from "@clerk/backend";
const clerk = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY,
publishableKey: process.env.CLERK_PUBLISHABLE_KEY,
});
export function clerkPlugin(): DinouPlugin {
return {
name: "clerk-auth",
async onRequest(request, context) {
// Cryptographically verify session token using standard Web Request
const requestState = await clerk.authenticateRequest(request);
const auth = requestState.toAuth();
// Attach verified credentials to context (AsyncLocalStorage)
context.userId = auth?.userId ?? null;
context.auth = auth;
},
};
}// dinou.config.mjs
import { defineConfig } from "dinou";
import { clerkPlugin } from "./plugins/clerk";
export default defineConfig({
ppr: true,
plugins: [
clerkPlugin(),
],
});Security Notice: Cryptographic Verification
__session cookie using raw Base64 decoders for authorization decisions. Always rely on Clerk's official verified bindings (authenticateRequest), which cryptographically validate the JWT token signature using your CLERK_SECRET_KEY.4. Usage in Pages & Server Functions
With the plugin registered, any Server Component, Page Function, or Server Action can read userId directly from getContext():
4.1 Securing Server Functions (Server Actions)
Server Functions run mutations on the server. You can protect them by fetching userId directly from the context:
// src/actions.ts (Server Function)
"use server";
import { getContext } from "dinou";
export async function createPost(title: string, content: string) {
const context = getContext();
const userId = context?.userId;
// Security Check
if (!userId) {
throw new Error("Unauthorized access. Invalid session.");
}
// Session is valid, save record to database
const post = await prisma.post.create({
data: {
title,
content,
authorClerkId: userId,
},
});
return post;
}Server Component vs. Client Component Architecture
await directly inside the component body and read getContext() with zero need for getProps.getProps in page_functions.ts is specifically designed for the Client-First Model, where page.tsx is an interactive Client Component ("use client") that cannot query databases or access server secrets directly.4.2 Option A: Server Component (Zero Boilerplate with Direct await)
If your dashboard page does not require client-side React hooks (like useState), keep it as a Server Component. Declare export const revalidate = 0; inside src/dashboard/page_functions.ts to enforce dynamic execution at request-time, and query Prisma directly inside src/dashboard/page.tsx with native async/await:
// src/dashboard/page_functions.ts
// Force on-demand SSR at request-time to load dynamic session
export const revalidate = 0;// src/dashboard/page.tsx (Server Component)
import { getContext, redirect } from "dinou";
import { prisma } from "@/lib/prisma";
export default async function DashboardPage() {
const context = getContext();
const userId = context?.userId;
// Protect route
if (!userId) {
return redirect("/login");
}
// Direct database query on Node.js / Edge without getProps
const posts = await prisma.post.findMany({
where: { authorClerkId: userId },
});
return (
<main className="max-w-4xl mx-auto p-8 space-y-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">Authenticated as: {userId}</p>
<div className="space-y-4">
{posts.map((post) => (
<article key={post.id} className="p-4 rounded-lg border bg-card">
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-sm text-muted-foreground mt-1">{post.content}</p>
</article>
))}
</div>
</main>
);
}4.3 Option B: Client Component ("use client" with getProps)
When page.tsx is an interactive Client Component (e.g. managing UI state with useState or using Clerk client hooks like useUser()), it runs in the browser and cannot connect to your database directly.
In this case, use getProps() in page_functions.ts to fetch data on the server, which Dinou automatically passes into your Client Component as initial props:
// src/dashboard/page_functions.ts
import { getContext, redirect } from "dinou";
import { prisma } from "@/lib/prisma";
// Force on-demand SSR at request-time to load dynamic session
export const revalidate = 0;
export async function getProps() {
const context = getContext();
const userId = context?.userId;
if (!userId) {
return redirect("/login");
}
const posts = await prisma.post.findMany({
where: { authorClerkId: userId },
});
return {
posts,
userId,
};
}// src/dashboard/page.tsx
"use client";
import type { PageProps } from "dinou";
import { useState } from "react";
interface DashboardProps extends PageProps<"/dashboard"> {
posts: Array<{ id: string; title: string; content: string }>;
userId: string;
}
export default function DashboardPage({ posts: initialPosts, userId }: DashboardProps) {
const [posts, setPosts] = useState(initialPosts);
return (
<main className="max-w-4xl mx-auto p-8 space-y-6">
<h1 className="text-3xl font-bold">Dashboard (Client Component)</h1>
<p className="text-muted-foreground">Authenticated as: {userId}</p>
<div className="space-y-4">
{posts.map((post) => (
<article key={post.id} className="p-4 rounded-lg border bg-card">
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-sm text-muted-foreground mt-1">{post.content}</p>
</article>
))}
</div>
</main>
);
}5. Client Setup (DinouClerkProvider)
To enable smooth Client-side SPA navigation and dynamic language changes in Clerk's built-in login forms, create a custom wrapper around ClerkProvider using Dinou's useRouter() hook:
// src/components/DinouClerkProvider.tsx
"use client";
import { ReactNode } from "react";
import { ClerkProvider } from "@clerk/react";
import { useRouter } from "dinou";
import { esES, enUS } from "@clerk/localizations";
// Customize localization text to match your app's brand
const customEnUS = {
...enUS,
formFieldLabel__emailAddress: "Email address (only sign in)",
signIn: {
...enUS.signIn,
start: {
...enUS.signIn.start,
title: "Sign in / up",
subtitle: "to continue to your app",
},
},
};
const customEsES = {
...esES,
formFieldLabel__emailAddress: "Correo electrónico (solo iniciar sesión)",
signIn: {
...esES.signIn,
start: {
...esES.signIn.start,
title: "Iniciar sesión / Registro",
subtitle: "para continuar a tu app",
},
},
};
const clerkLocales = {
es: customEsES,
en: customEnUS,
};
export function DinouClerkProvider({
children,
publishableKey,
locale = "en",
}: {
children: ReactNode;
publishableKey: string;
locale?: "en" | "es";
}) {
const router = useRouter();
return (
<ClerkProvider
publishableKey={publishableKey}
// Integrate with Dinou client router for smooth SPA soft transitions
navigate={(to) => router.push(to)}
// Dynamically select custom translations mapping the active locale
localization={clerkLocales[locale]}
appearance={{
elements: {
footerAction: { display: "none" }, // Optional: Hide sign up link
},
}}
>
{children}
</ClerkProvider>
);
}5.1 Wrapping the Root Layout
In your root src/layout.tsx, wrap the children tree with your custom provider:
// src/layout.tsx
import { DinouClerkProvider } from "@/components/DinouClerkProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<DinouClerkProvider
publishableKey={process.env.CLERK_PUBLISHABLE_KEY || ""}
>
{children}
</DinouClerkProvider>
</body>
</html>
);
}6. Webhooks & Database Sync (Plugin Pattern)
In Dinou v7, webhook endpoints are handled directly inside plugins using the Short-Circuit Pattern. When a plugin returns a Web Response, Dinou returns that response immediately and bypasses React Server Component rendering entirely:
// plugins/clerk-webhook.ts
import type { DinouPlugin } from "dinou";
import { Webhook } from "svix";
export function clerkWebhookPlugin(): DinouPlugin {
return {
name: "clerk-webhook",
async onRequest(request) {
const url = new URL(request.url);
// Intercept incoming Clerk webhook requests
if (url.pathname === "/api/webhooks/clerk" && request.method === "POST") {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
const svix_id = request.headers.get("svix-id");
const svix_timestamp = request.headers.get("svix-timestamp");
const svix_signature = request.headers.get("svix-signature");
if (!WEBHOOK_SECRET || !svix_id || !svix_timestamp || !svix_signature) {
return new Response("Missing svix signature headers", { status: 400 });
}
// Web Standards: request.text() reads the unparsed raw UTF-8 body
const payload = await request.text();
const wh = new Webhook(WEBHOOK_SECRET);
let evt: any;
try {
evt = wh.verify(payload, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
});
} catch (err) {
return Response.json({ error: "Invalid signature" }, { status: 400 });
}
const { id } = evt.data;
const eventType = evt.type;
if (eventType === "user.created") {
const email = evt.data.email_addresses[0]?.email_address || `${id}@no-email.com`;
await prisma.user.upsert({
where: { clerkId: id },
update: { email },
create: { clerkId: id, email },
});
}
if (eventType === "user.deleted") {
await prisma.user.delete({ where: { clerkId: id } }).catch(() => {});
}
// Short-circuit: return HTTP 200 JSON directly to Clerk
return Response.json({ received: true });
}
},
};
}Web Standards Advantage: No Body-Parser Issues
Request.text() yields the pristine, untouched body string, cryptographic signature verification with svix succeeds reliably out of the box. You no longer need to worry about Express raw buffer middleware order or server ejection.