next-forge
next-forge copied to clipboard
API as a service
Is your feature request related to a problem? Please describe. More of a discussion (maybe we need a discussions tab). My understanding was that the API was to be used as a standalone API service rather then just a functions wrapper.
In most of our SaaS products the API is a seperate service. Currently apps/api
there is no way to easily verify a user.
The solution I went with In the interest of keeping strong seperation of concern I have setup the API as follows.
https://api.mydomain.com
Middleware
In the middleware for the API I do a few things.
export function middleware(request: NextRequest) {
// if someone tries to access this as a page it will redirect to the web url
if (request.nextUrl.pathname === '/') {
return NextResponse.redirect(env.NEXT_PUBLIC_WEB_URL, {
status: 308 // Permanent redirect
});
}
// We always fetch on the server within the core app so securing routes with a simple api key is a fallback safeguard
const apiKey = request.headers.get('x-api-key');
if (apiKey !== env.API_KEY) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
// This only enforces these checks in the /api folder. this means /webhooks runs without middleware
export const config = {
matcher: ['/', '/api/:path*']
}
Server Component/Page Fetching
How do we attach the user to the api? Clerk has a few different methods but the easiest way I found was just to include the Auth header. I created a fetch function similar to this that I can call from any server component in app or web or any other microservice we create.
import "server-only"
import { headers, cookies } from "next/headers"
import { env } from "@repo/env"
export async function getApiKey() {
const headersList = await headers()
return headersList.get("x-api-key")
}
export const getSession = async () => {
const cookieStore = await cookies()
const session = cookieStore.get("__session")
if (!session) {
return null
}
return session.value
}
export async function serverFetch<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${await getSession()}`,
"x-api-key": `${env.CUSTOM_API_KEY}`,
...(init?.headers || {}),
},
})
return response.json()
}
This attached the session to the auth header as Clerk expects it.
Server Actions/Forms
The intent for Server Actions becomes really obvious in this now. We can use server actions to invoke the API. Here is a very simple one I use for submitting a form.
in apps/app/app/actions/nameAction.ts
"use server"
import { log } from "@repo/observability/log"
import { serverFetch } from "@/lib/helpers"
// name here is just a very simple body but you could add form validation and more complex data
export async function createGarageAction(name: string): Promise<MyResponse<boolean>> {
const MY_ROUTE = "https://api.domain.com/your/path"
const { data, error } = await serverFetch<DoriResponse<boolean>>(MY_ROUTE, {
method: "POST",
body: JSON.stringify({name})
})
if (error) {
log.error(error)
return { data: null,error: "Failed to create garage" }
}
return { data: true, error: null }
}
Clerk In API
After this we can create the user session on the server.
If you use the JWT public key it will verify the session without sending a request to Clerks backend. I have created a function like so.
export const getValidClerkSession = async (req: NextRequest): Promise<{user: UserSelect, context: UserMinContext}> => {
const clerk = await clerkClient()
const auth = await clerk.authenticateRequest(req, {
jwtKey: env.CLERK_JWT_KEY,
authorizedParties: [WEB, API, APP], // these are the urls for that specific environmnet
});
if (!auth.isSignedIn) {
throw new AuthorizationError(`Unauthorized - ${auth.reason || 'Please sign in'}`);
}
const { userId: clerkId } = await auth.toAuth()
// here I get some more info about the user that can be used in any api route that requires a user but this is not needed
const userRepository = new UserRepository(database)
const user = await userRepository.getUserByClerkId(clerkId)
if (!user) {
throw new AuthorizationError(`User not found - ${clerkId}`)
}
const context: UserContext = {
clerkId: user.clerkId,
userId: user.id,
email: user.email,
username: user.username || '',
}
// returning this is extendable and gives us all the info we might need on a base user in the api route
return { user, context }
}
Then I just call this first in any API route that needs a user attached
const { context } = await getValidClerkSession(request)
Results
This makes the API a completely stand alone system that can still use the Clerk user for auth.
With how Next.js works it adds a strong seperation of concern. It also makes it extendable or replaceable. They push heavy that data fetching/mutations should happen server side. This has the benefit's of both and prevents any valuable info being leaked to the client.
Shortcomings
I am aware that calling a server action in itself is an api request. In early testing in other projects the impact with not really a concern. The benefits out weighed the negatives.
While this is definitely more code, I think its one of the core reasons why this repo is so popular. Everything is seperate, it makes it very easy to find things.
This extends on that fact. It felt a bit messy putting API routes in the app when we had a dedicated API service.
Describe alternatives you've considered I would love some feedback on this. Maybe it can be improved.