Custom HTTP API Endpoints
In Wasp, the default client-server interaction mechanism is through Operations. However, if you need a specific URL method/path, or a specific response, Operations may not be suitable for you. For these cases, you can use an api. Best of all, they should look and feel very familiar.
How to Create an APIβ
APIs are used to tie a JS function to a certain endpoint e.g. POST /something/special. They are distinct from Operations and have no client-side helpers (like useQuery).
To create a Wasp API, you must:
- Declare the API in Wasp using the
apideclaration - Define the API's NodeJS implementation
After completing these two steps, you'll be able to call the API from the client code (via our Axios wrapper), or from the outside world.
Declaring the API in Waspβ
First, we need to declare the API in the Wasp file and you can easily do this with the api declaration:
// ...
apifooBar{// APIs and their implementations don't need to (but can) have the same name.
fn: import{ fooBar }from"@src/apis",
httpRoute: (GET,"/foo/bar")
}
Read more about the supported fields in the API Reference.
Defining the API's NodeJS Implementationβ
After you defined the API, it should be implemented as a NodeJS function that takes three arguments:
req: Express Request objectres: Express Response objectcontext: An additional context object injected into the API by Wasp. This object contains user session information, as well as information about entities. The examples here won't use the context for simplicity purposes. You can read more about it in the section about using entities in APIs.
- JavaScript
- TypeScript
exportconstfooBar=(req, res, context)=>{
res.set("Access-Control-Allow-Origin","*");// Example of modifying headers to override Wasp default CORS middleware.
res.json({msg:`Hello, ${context.user?"registered user":"stranger"}!`});
};
importtype{ FooBar }from"wasp/server/api";
exportconst fooBar:FooBar=(req, res, context)=>{
res.set("Access-Control-Allow-Origin","*");// Example of modifying headers to override Wasp default CORS middleware.
res.json({ msg:`Hello, ${context.user ?"registered user":"stranger"}!`});
};
Using the APIβ
Using the API externallyβ
To use the API externally, you simply call the endpoint using the method and path you used.
For example, if your app is running at https://example.com then from the above you could issue a GET to https://example/com/foo/callback (in your browser, Postman, curl, another web service, etc.).
Using the API from the Clientβ
To use the API from your client, including with auth support, you can import the Axios wrapper from wasp/client/api and invoke a call. For example:
- JavaScript
- TypeScript
importReact,{ useEffect }from"react";
import{ api }from"wasp/client/api";
asyncfunctionfetchCustomRoute(){
const res =await api.get("/foo/bar");
console.log(res.data);
}
exportconstFoo=()=>{
useEffect(()=>{
fetchCustomRoute();
},[]);
return<>{/* ... */}</>;
};
importReact,{ useEffect }from"react";
import{ api }from"wasp/client/api";
asyncfunctionfetchCustomRoute(){
const res =await api.get("/foo/bar");
console.log(res.data);
}
exportconstFoo=()=>{
useEffect(()=>{
fetchCustomRoute();
},[]);
return<>{/* ... */}</>;
};
Making Sure CORS Worksβ
APIs are designed to be as flexible as possible, hence they don't utilize the default middleware like Operations do. As a result, to use these APIs on the client side, you must ensure that CORS (Cross-Origin Resource Sharing) is enabled.
You can do this by defining custom middleware for your APIs in the Wasp file.
For example, an apiNamespace is a simple declaration used to apply some middlewareConfigFn to all APIs under some specific path:
apiNamespacefooBar{
middlewareConfigFn: import{ fooBarNamespaceMiddlewareFn }from"@src/apis",
path: "/foo"
}
And then in the implementation file (returning the default config):
- JavaScript
- TypeScript
exportconstapiMiddleware=(config)=>{
return config;
};
importtype{ MiddlewareConfigFn }from"wasp/server";
exportconst apiMiddleware:MiddlewareConfigFn=(config)=>{
return config;
};
We are returning the default middleware which enables CORS for all APIs under the /foo path.
For more information about middleware configuration, please see: Middleware Configuration
Using Entities in APIsβ
In many cases, resources used in APIs will be Entities.
To use an Entity in your API, add it to the api declaration in Wasp:
apifooBar{
fn: import{ fooBar }from"@src/apis",
entities: [Task],
httpRoute: (GET,"/foo/bar")
}
Wasp will inject the specified Entity into the APIs context argument, giving you access to the Entity's Prisma API:
- JavaScript
- TypeScript
exportconstfooBar=async(req, res, context)=>{
res.json({count:await context.entities.Task.count()});
};
importtype{ FooBar }from"wasp/server/api";
exportconst fooBar:FooBar=async(req, res, context)=>{
res.json({ count:await context.entities.Task.count()});
};
The object context.entities.Task exposes prisma.task from Prisma's CRUD API.
Streaming Responsesβ
You can use streaming responses to send data to the client in chunks as it becomes available. This is useful for:
- LLM responses - Stream AI-generated content as it's produced
- Long-running processes - Show progress updates in real-time
- Large datasets - Send data incrementally to avoid timeouts
Creating a Streaming APIβ
To create a streaming API, write a function that uses Express response methods like res.write() and res.end():
apistreamingText{
httpRoute: (POST,"/api/streaming-example"),
fn: import{ getStreamingText }from"@src/streaming",
}
Don't forget to set up the CORS middleware. See the section explaning CORS for details.
- JavaScript
- TypeScript
importOpenAIfrom"openai";
const client =newOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
exportconstgetStreamingText=async(req, res)=>{
const{ message }= req.body;
// Set appropriate headers for streaming.
res.setHeader("Content-Type","text/plain; charset=utf-8");
res.setHeader("Transfer-Encoding","chunked");
const stream =await client.responses.create({
model:"gpt-5",
input:`Funny response to "${message}"`,
stream:true,
});
forawait(const chunk of stream){
if(chunk.type==="response.output_text.delta"){
// Write each chunk to the response as it arrives.
res.write(chunk.delta);
}
}
// End the response.
res.end();
};
import OpenAI from"openai";
importtype{ StreamingText }from"wasp/server/api";
const client =newOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
exportconst getStreamingText: StreamingText<
never,
string,
{ message:string}
>=async(req, res)=>{
const{ message }= req.body;
// Set appropriate headers for streaming.
res.setHeader("Content-Type","text/plain; charset=utf-8");
res.setHeader("Transfer-Encoding","chunked");
const stream =await client.responses.create({
model:"gpt-5",
input:`Funny response to "${message}"`,
stream:true,
});
forawait(const chunk of stream){
if(chunk.type ==="response.output_text.delta"){
// Write each chunk to the response as it arrives.
res.write(chunk.delta);
}
}
// End the response.
res.end();
};
Consuming Streaming Responsesβ
There are two ways you can consume streaming responses on the client side: using the Fetch API or using Axios.
We recommend using the Fetch API becuase it supports streaming natively. You'll need to handle auth manually by adding the Authorization header.
Axios doesn't natively support streaming responses and you have use the onDownloadProgress callback to simulate it.
Wasp internally uses Axios and exposes an Axios wrapper via wasp/client/api which handles auth automatically.
Using the Fetch APIβ
Here's an example showing how to consume streaming responses using the Fetch API:
- JavaScript
- TypeScript
import{ useEffect, useState }from"react";
import{ config }from"wasp/client";
import{ getSessionId }from"wasp/client/api";
exportfunctionStreamingPage(){
const{ response }=useTextStream("/api/streaming-example",{
message:"Best Office episode?",
});
return(
<div>
<h1>Streaming Example</h1>
<pre>{response}</pre>
</div>
);
}
functionuseTextStream(path, payload){
const[response, setResponse]=useState("");
useEffect(()=>{
const controller =newAbortController();
fetchStream(
path,
payload,
(chunk)=>{
setResponse((prev)=> prev + chunk);
},
controller.signal,
);
return()=>{
controller.abort();
};
},[path]);
return{ response };
}
asyncfunctionfetchStream(path, payload, onData, signal){
const sessionId =getSessionId();
try{
const response =awaitfetch(config.apiUrl+ path,{
method:"POST",
headers:{
"Content-Type":"application/json",
...(sessionId &&{Authorization:`Bearer ${sessionId}`}),
},
body:JSON.stringify(payload),
signal,
});
if(!response.ok){
thrownewError(`HTTP error! status: ${response.status}`);
}
if(response.body===null){
thrownewError("Stream body is null");
}
const stream = response.body.pipeThrough(newTextDecoderStream());
const reader = stream.getReader();
while(true){
const{ done, value }=await reader.read();
if(done){
break;
}
onData(value);
}
}catch(error){
if(error instanceofError){
if(error.name==="AbortError"){
// Fetch was aborted, no need to log an error
return;
}
console.error("Fetch error:", error.message);
}else{
throw error;
}
}
}
import{ useEffect, useState }from"react";
import{ config }from"wasp/client";
import{ getSessionId }from"wasp/client/api";
exportfunctionStreamingPage(){
const{ response }=useTextStream("/api/streaming-example",{
message:"Best Office episode?",
});
return(
<div>
<h1>Streaming Example</h1>
<pre>{response}</pre>
</div>
);
}
functionuseTextStream(path:string, payload:{ message:string}){
const[response, setResponse]=useState("");
useEffect(()=>{
const controller =newAbortController();
fetchStream(
path,
payload,
(chunk)=>{
setResponse((prev)=> prev + chunk);
},
controller.signal,
);
return()=>{
controller.abort();
};
},[path]);
return{ response };
}
asyncfunctionfetchStream(
path:string,
payload:{ message:string},
onData:(data:string)=>void,
signal:AbortSignal,
){
const sessionId =getSessionId();
try{
const response =awaitfetch(config.apiUrl+ path,{
method:"POST",
headers:{
"Content-Type":"application/json",
...(sessionId &&{Authorization:`Bearer ${sessionId}`}),
},
body:JSON.stringify(payload),
signal,
});
if(!response.ok){
thrownewError(`HTTP error! status: ${response.status}`);
}
if(response.body===null){
thrownewError("Stream body is null");
}
const stream = response.body.pipeThrough(newTextDecoderStream());
const reader = stream.getReader();
while(true){
const{ done, value }=await reader.read();
if(done){
break;
}
onData(value);
}
}catch(error:unknown){
if(error instanceofError){
if(error.name==="AbortError"){
// Fetch was aborted, no need to log an error
return;
}
console.error("Fetch error:", error.message);
}else{
throw error;
}
}
}
Using Axiosβ
Here's an example showing how to consume streaming responses using the Axios wrapper from wasp/client/api:
- JavaScript
- TypeScript
import{ useEffect, useState }from"react";
import{ api }from"wasp/client/api";
exportfunctionStreamingPage(){
const{ response }=useAxiosTextStream("/api/streaming-example",{
message:"Best Office episode?",
});
return(
<div>
<h1>Axios Streaming</h1>
<pre>{response}</pre>
</div>
);
}
functionuseAxiosTextStream(path, payload){
const[response, setResponse]=useState("");
useEffect(()=>{
const controller =newAbortController();
fetchAxiosStream(
path,
payload,
(data)=>{
setResponse(data);
},
controller.signal,
);
return()=>{
controller.abort();
};
},[path]);
return{ response };
}
asyncfunctionfetchAxiosStream(path, payload, onData, signal){
try{
returnawait api.post(path, payload,{
responseType:"stream",
signal,
onDownloadProgress:(progressEvent)=>{
const xhr = progressEvent.event.target;
onData(xhr.responseText);
},
});
}catch(error){
if(error instanceofError){
if(error.name==="CanceledError"){
// Request was cancelled, no action needed
}else{
console.error("Fetch error:", error);
}
}else{
throw error;
}
}
}
import{ useEffect, useState }from"react";
import{ api }from"wasp/client/api";
exportfunctionStreamingPage(){
const{ response }=useAxiosTextStream("/api/streaming-example",{
message:"Best Office episode?",
});
return(
<div>
<h1>Axios Streaming</h1>
<pre>{response}</pre>
</div>
);
}
functionuseAxiosTextStream(path:string, payload:{ message:string}){
const[response, setResponse]=useState("");
useEffect(()=>{
const controller =newAbortController();
fetchAxiosStream(
path,
payload,
(data)=>{
setResponse(data);
},
controller.signal,
);
return()=>{
controller.abort();
};
},[path]);
return{ response };
}
asyncfunctionfetchAxiosStream(
path:string,
payload:{ message:string},
onData:(data:string)=>void,
signal:AbortSignal,
){
try{
returnawait api.post(path, payload,{
responseType:"stream",
signal,
onDownloadProgress:(progressEvent)=>{
const xhr = progressEvent.event.target;
onData(xhr.responseText);
},
});
}catch(error:unknown){
if(error instanceofError){
if(error.name==="CanceledError"){
// Request was cancelled, no action needed
}else{
console.error("Fetch error:", error);
}
}else{
throw error;
}
}
}
API Referenceβ
apifooBar{
fn: import{ fooBar }from"@src/apis",
httpRoute: (GET,"/foo/bar"),
entities: [Task],
auth: true,
middlewareConfigFn: import{ apiMiddleware }from"@src/apis"
}
The api declaration has the following fields:
-
fn: ExtImportrequiredThe import statement of the APIs NodeJs implementation.
-
httpRoute: (HttpMethod, string)requiredThe HTTP (method, path) pair, where the method can be one of:
ALL,GET,POST,PUTorDELETE- and path is an Express path
string.
-
entities: [Entity]A list of entities you wish to use inside your API. You can read more about it here.
-
auth: boolIf auth is enabled, this will default to
trueand provide acontext.userobject. If you do not wish to attempt to parse the JWT in the Authorization Header, you should set this tofalse. -
middlewareConfigFn: ExtImportThe import statement to an Express middleware config function for this API. See more in middleware section of the docs.