Proxy Backend Requests in Next.js
Here are some ways to proxy requests to a different service using a proxy endpoint in a Next.js app.
Next Config Rewrites
Next.js has a configuration that allows you to modify requests to its Node server and rewrite them as a request to a different host. Modify your next.config.mjs to include:
export default {
async rewrites() {
return [
{
source: '/proxy/myservice',
destination: process.env.MY_SERVICE_HOST + '/some-service-endpoint',
},
]
},
}
This requires the least code. You must also be able to have environment vars such as MY_SERVICE_HOST available at next build time. You must also have authorization available in the client, which might not always be possible, and it'll need to be secure.
Reverse Proxy
If the rewrites method doesn't give you enough flexibility, you could create your own Next.js api route that acts as a reverse proxy for your other service. Put this file at src/app/proxy/myservice/route.ts
export async function GET(request: Request) {
return proxyToMyServer(request)
}
export async function POST(request: Request) {
return proxyToMyServer(request)
}
async function proxyToMyServer(request: Request) {
const targetUrl = `${process.env.MY_SERVER_API_HOST}/some-service-endpoint`
const response = await fetch(targetUrl, {
method: request.method,
headers: {
'Content-Type': 'application/json',
Authorization: `${process.env.MY_SERVICE_API_KEY}`,
},
body: request.body,
duplex: 'half',
})
return new Response(response.body, {
status: response.status,
headers: response.headers,
})
}
You could implement other HTTP methods here as well.
Then in your client networking code, you detect whether your already on the server or not when you make the request. If you're on the server, make the other service request directly. If you're on the client, make the request to your proxy endpoint.
export function fetchMyServiceGql(params: {
query: string
variables?: Record<string, any>
headers?: Record<string, any>
}): Promise<Response> {
const { headers, ...body } = params
return fetch(isServer() ? process.env.MY_SERVICE_API_HOST + '/some-service-endpoint' : '/proxy/myserver', {
method: 'POST',
headers: {
Accept: 'application/json',
Connection: 'keep-alive',
'Content-Type': 'application/json',
...headers,
},
cache: 'no-store',
body: JSON.stringify(body),
})
}
export function isServer() {
return typeof window === 'undefined'
}
Dynamic RPC endpoint
You probably want to use the rewrites or proxy. But, if you want even more control, you can have it, and you can even write it in a generalized way.
We can create a Next.js api route that accepts data about a function to call on the backend. We validate that request, and then call the function. Remote procedure call. We'll deploy the endpoint at src/app/rpc/fn/route.ts:
import { myFunction } from './somewhere/my-function'
import { validateNonEmpty } from '@/common/data/validation/primitive-validators'
import { validateRpcArgs, ValidateRpcFunction } from './rpc-validation'
// ...import getAccessToken, Result, SchemaValidationError, etc.
const SUPPORTED_FNS: Record<string, FnEnv> = {
myFunction: {
validate: validateRpcArgs([['someInputId', [validateNonEmpty]]]),
exec: myFunction,
},
}
export async function POST(request: Request) {
try {
const body = await request.json()
if (!body.name) return respondError(400, new Error('Specify a function'))
const env = chooseFnEnv(body.name)
if (!env) return respondError(404, new Error('Unknown function'))
if (env.validate(body.args)) {
const argsResult = await mapArgsForExec(body.args)
if (!argsResult.ok) return respondError(500, argsResult.error)
const result = await env.exec.apply(null, argsResult.value)
return result.ok ? Response.json({ data: result.value }) : respondError(500, result.error)
} else {
return respondError(400, new SchemaValidationError(env.validate.errors))
}
} catch (err) {
return respondError(500, err instanceof Error ? err : new Error('Failed to run function'))
}
}
interface FnEnv {
// DESIGN NOTE - any type is purposeful here. We will take any shape of args. We don't predict
// anything at compile time. At runtime, we validate the data input. Then
// we respond with whatever the output is. We have to run-time validate anyway because
// we're dealing with the network.
validate: ValidateRpcFunction
exec: (...args: any) => Promise<Result<any>>
}
function chooseFnEnv(name: string): FnEnv | undefined {
return SUPPORTED_FNS[name]
}
async function mapArgsForExec(args: any[]): Promise<Result<any[]>> {
const accessTokenResult = getAccessToken(await cookies())
if (!accessTokenResult.ok) return accessTokenResult
return { ok: true, value: [...args, accessTokenResult.value] }
}
There's a lot of parsing and validating requests here. Here's how we might take care of that:
// import ... error object, etc
type RpcValidatorConfig = [string, Validator[]][]
export interface ValidateRpcFunction {
(...args: any): boolean
errors: ErrorObject[]
}
export function validateRpcArgs(config: RpcValidatorConfig): ValidateRpcFunction {
function validateArgs(args: any): boolean {
const errors = mapErrors(config, args)
validateArgs.errors = errors
return errors.length === 0
}
validateArgs.errors = [] as ErrorObject[]
return validateArgs
}
function mapErrors(config: RpcValidatorConfig, args: any[]) {
return args.flatMap((arg, i) => {
const argConfig = config[i]
if (!argConfig) throw new Error(`Failed to validate arg. Missing ${i}${formatOrderSuffix(i)} arg.`)
const [name, validations] = argConfig
return validations
.map((validation) => validation(name as string, arg))
.filter((output) => !output.valid)
.map((output) => mapErrorObject(name as string, output))
})
}
And here are what some of the field-level, primitive validators might look like:
// import ...titlize, date stuff
export function validateNonEmpty(name: string, value: any) {
return {
valid: value !== undefined && value !== null && value.toString().trim() !== '',
message: `${titleize(name)} is required.`,
}
}
export function validateIsoDate(name: string, value: any) {
return {
valid: isValidIso8601Date(value),
message: `${titleize(name)} must be a valid ISO-8601 date.`,
}
}
There's a lot of code that goes into a solution like this. That means lots of room for error and unmet features. And you're probably re-implementing those validations or some variation of them in your other service as well, so there's duplicate effort.
Avoid the network hop if you can help it. If not, prefer the lightest proxy you can manage.