How to Make a Next.js POST Request to External APIs: Server and Client Patterns
Use API Routes with Edge or Node runtimes to proxy sensitive POST requests server-side, and leverage SWR or React Query for client-side mutations to handle loading states and caching automatically.
Making a next js post request to external services requires careful consideration of where the code executes—whether during server-side rendering, inside an API route, or in the browser. The Next.js framework provides multiple patterns for handling POST requests securely and efficiently, from Edge runtime handlers to client-side data mutations. This guide examines concrete implementations from the Vercel next.js repository to help you choose the right approach for your use case.
Server-Side Patterns for Next.js POST Requests
Server-side execution keeps sensitive credentials out of the browser and allows you to pre-render HTML with external data. Next.js offers two primary server environments: Node.js and Edge runtimes.
Secure Proxying with API Routes
When your external API requires secret keys or OAuth tokens, create a proxy endpoint in app/api/*/route.ts (App Router) or pages/api/* (Pages Router). This pattern keeps authentication headers server-side while exposing only your own endpoint to the client.
The examples/with-redux/app/api/counter/route.ts file demonstrates forwarding a POST request:
export async function POST(request: Request) {
const body = await request.json();
// Forward to external API with secret auth
const res = await fetch('https://api.external-service.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.API_SECRET_KEY}`
},
body: JSON.stringify(body)
});
const data = await res.json();
return Response.json(data);
}
Edge Runtime for Low-Latency POST Requests
For geographically distributed users or webhook handlers, use the Edge runtime by exporting const runtime = 'edge'. Edge functions run closer to the user, start instantly, and use the native Web fetch API.
The examples/with-stripe-typescript/app/api/webhooks/route.ts file shows an Edge handler for external webhook POST requests:
export const runtime = 'edge';
export async function POST(req: Request) {
const payload = await req.text();
const signature = req.headers.get('stripe-signature');
// Verify and forward to Stripe or process externally
const response = await fetch('https://api.stripe.com/v1/events', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: payload
});
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
Server-Side Rendering with getServerSideProps
For Pages Router applications requiring SEO-critical data at request time, use getServerSideProps to make POST requests during server rendering. The examples/blog/pages/posts/pages.md demonstrates fetching data before sending HTML to the client.
// pages/dashboard.tsx
import { GetServerSideProps } from 'next';
export const getServerSideProps: GetServerSideProps = async (context) => {
// Server-side POST request during SSR
const res = await fetch('https://api.analytics.com/reports', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.ANALYTICS_KEY}` },
body: JSON.stringify({ userId: context.req.cookies.userId })
});
const data = await res.json();
return {
props: { analyticsData: data }
};
};
export default function Dashboard({ analyticsData }) {
return <div>{/* Render data */}</div>;
}
Client-Side Patterns for Next.js POST Requests
When user interactions trigger data changes, handle POST requests in the browser to maintain responsive UI states.
Direct Fetch in Event Handlers
For simple form submissions, call fetch directly inside event handlers. This pattern requires no additional libraries but manually manages loading and error states.
'use client';
import { useState } from 'react';
export default function ContactForm() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
try {
const res = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData)),
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error('Failed to submit');
// Handle success
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button disabled={isLoading}>Submit</button>
{error && <p>{error}</p>}
</form>
);
}
Using SWR for Mutations
For automatic caching, deduplication, and revalidation, use useSWRMutation from the SWR library. This pattern works best when paired with an API route proxy.
'use client';
import useSWRMutation from 'swr/mutation';
const fetcher = async (url: string, { arg }: { arg: { title: string } }) => {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(arg)
});
if (!res.ok) throw new Error('Failed to create');
return res.json();
};
export default function NewTodo() {
const { trigger, isMutating, error } = useSWRMutation('/api/todos', fetcher);
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await trigger({ title: formData.get('title') as string });
e.currentTarget.reset();
};
return (
<form onSubmit={onSubmit}>
<input name="title" required />
<button disabled={isMutating}>Add Todo</button>
{error && <p>Failed: {error.message}</p>}
</form>
);
}
React Query useMutation Hook
For complex state management including optimistic updates, use TanStack Query's useMutation. This provides built-in error handling and rollback capabilities.
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export default function CreatePost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newPost: { title: string; body: string }) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost)
});
if (!res.ok) throw new Error('Network response was not ok');
return res.json();
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: (error) => {
console.error('Failed to create post:', error);
}
});
return (
<button
onClick={() => mutation.mutate({ title: 'New Post', body: 'Content' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
);
}
Security and Performance Best Practices
When implementing a next js post request, protect sensitive data and optimize for your runtime environment.
Protecting Secrets and Handling CORS
Never expose API keys in client-side code. Instead, proxy requests through API routes where process.env variables remain server-side. This pattern also eliminates CORS issues since the browser communicates only with your same-origin endpoint.
In examples/with-stripe-typescript/app/api/webhooks/route.ts, the implementation demonstrates verifying signatures and injecting the STRIPE_SECRET_KEY environment variable without ever exposing it to the client.
Streaming and Large Payloads
For file uploads or large JSON payloads, stream data directly to the external API rather than buffering entire requests in memory. In Edge runtimes, use req.body.getReader() to process streams efficiently.
Timeouts and Error Handling
Wrap external fetch calls with AbortController to prevent hanging requests from blocking your API routes:
export async function POST(req: Request) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('https://api.external.com/data', {
method: 'POST',
body: await req.text(),
signal: controller.signal
});
clearTimeout(timeout);
return Response.json(await res.json());
} catch (error) {
clearTimeout(timeout);
return Response.json({ error: 'Request timeout' }, { status: 504 });
}
}
Summary
- Use API routes in
app/api/*/route.tsorpages/api/*to proxy next js post request calls, keeping secrets likeSTRIPE_SECRET_KEYserver-side as shown inexamples/with-stripe-typescript/app/api/webhooks/route.ts. - Choose Edge runtime (
export const runtime = 'edge') for low-latency webhooks or geo-distributed APIs, leveraging nativefetchwithout Node.js overhead. - Implement client-side mutations with
useSWRMutationor TanStack Query to handle loading states, optimistic updates, and automatic error handling when calling your proxied API endpoints. - Never expose API keys in React components; always route external requests through your own API layer to prevent CORS issues and credential leakage.
- Stream large payloads and implement
AbortControllertimeouts in API routes to prevent memory exhaustion and hanging connections.
Frequently Asked Questions
How do I keep API keys secret when making a Next.js POST request?
Create an API route in app/api/your-route/route.ts that forwards the request to the external service. Store the API key in environment variables like process.env.API_SECRET, which are only accessible server-side. The client sends data to your API route, which then adds the authentication headers before calling the external endpoint, as demonstrated in examples/with-redux/app/api/counter/route.ts.
Should I use Edge runtime or Node.js runtime for API routes?
Use Edge runtime (export const runtime = 'edge') for low-latency requirements, webhook handlers, or when you need to execute close to the user's geographic location. Edge functions start instantly and use the Web standard fetch API. Use Node.js runtime (the default) when you need native Node modules, file system access, or complex data processing that requires the full Node.js ecosystem.
How do I handle CORS errors when calling external APIs from the browser?
Instead of calling the external API directly from your React components—which triggers CORS preflight checks—route the request through your Next.js API route. The browser communicates with your same-origin endpoint (/api/external), and your server-side code makes the actual cross-origin request. This eliminates CORS restrictions while keeping your API keys secure.
What is the best way to handle loading states during client-side POST requests?
Use data-fetching libraries like SWR (useSWRMutation) or TanStack Query (useMutation). These hooks automatically manage isLoading/isMutating states, handle error retries, and support optimistic updates that immediately reflect changes in the UI before the server confirms. This prevents you from manually tracking useState for loading indicators and ensures consistent error handling across your application.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →