Deploying a Self-Hosted Expo API Routes using Docker

Expo API Routes were introduced with Expo 50, and it's a feature that is still not well-known. New developers tend to miss the power of this feature.
Expo Routes introduce page navigation similar to Next.js but can also create server endpoints and REST API in WinterGC-compliant environment.
In other words, you can write a GET/POST/PUT
routes that can handle server logic in the same app folder!
With Server Side Rendering comming to Expo 52 in the next release, hosting will be crucial to make your app performant and scalable.
Let’s dive into how to use Expo Routes to create a custom API endpoint that we can use in our app:
npx create-expo-app
First, let’s set the output of the bundler to a server instead of static. This will generate server code as well as a static web app for your Expo project:
"web": {
"bundler": "metro",
"output": "server"
}
Let’s create our first REST API. Inside the app project folder, a file with +api.ts
will be treated as a route API and should export POST, GET functions to handle the request:
touch app/api/echo+api.ts
Here we just parse the body and return it as a response:
export async function POST(request: Request) {
const message = await request.json();
return Response.json(message);
}
Run the development server to test our new API:
npm run start
> expo start
› Metro waiting on exp://192.168.0.228:8081
› Web is waiting on http://localhost:8081
› Using Expo Go
› Press s │ switch to development build
› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web
› Press j │ open debugger
› Press r │ reload app
› Press m │ toggle menu
› Press o │ open project code in your editor
› Press ? │ show all commands
Logs for your project will appear below. Press Ctrl+C to exit.
Now the /api/echo should be available at http://localhost:8081/api/echo
curl -X POST http://localhost:8081/api/echo \
-H "Content-Type: application/json" \
-d '{"hello":"world"}'
{"hello":"world"}
You can also call the new API within your app:
import { Button } from 'react-native';
async function fetchEcho() {
const response = await fetch('/api/echo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: 'Hello' }),
});
const data = await response.json();
alert(JSON.stringify(data));
}
export default function HomeScreen() {
return <Button onPress={() => fetchEcho()} title="Call Echo" />;
}
Note that this will only work for the web since it runs on the same host. For production, you should set the origin for expo-router in the app.json file:
"plugins": [
["expo-router", {
"origin": "http://192.168.0.228:8080"
}]
],
Next, let’s export our project. This command will bundle all the functions and static files into a single dist folder:
npm run expo export -p web
This will generate a dist directory with client and server.
npm install express compression morgan -D
Expo documentation provides a server script based on Express.js that can serve the exported project:
#!/usr/bin/env node
const path = require('path');
const { createRequestHandler } = require('@expo/server/adapter/express');
const express = require('express');
const compression = require('compression');
const morgan = require('morgan');
const CLIENT_BUILD_DIR = path.join(process.cwd(), 'dist/client');
const SERVER_BUILD_DIR = path.join(process.cwd(), 'dist/server');
const app = express();
app.use(compression());
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable('x-powered-by');
process.env.NODE_ENV = 'production';
app.use(
express.static(CLIENT_BUILD_DIR, {
maxAge: '1h',
extensions: ['html'],
})
);
app.use(morgan('tiny'));
app.all(
'*',
createRequestHandler({
build: SERVER_BUILD_DIR,
})
);
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Express server listening on port ${port}`);
});
You should be able to run the server locally with:
node server.js
Express server listening on port 3000
You can host the project by just copying the dist and server.js and hosting it on any server manually.
But, let's use Docker to make a nice container that we can deploy anywhere:
FROM node:20-alpine AS base
FROM base AS builder
RUN apk add --no-cache gcompat
WORKDIR /app
COPY . ./
RUN npm ci && \
npm run export && \
npm prune --production
FROM base AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodejs
RUN npm install express compression morgan @expo/server
COPY --from=builder --chown=nodejs:nodejs /app/dist /app/dist
COPY --from=builder --chown=nodejs:nodejs /app/server.js /app/server.js
USER nodejs
EXPOSE 3000
CMD ["node", "/app/server.js"]
With this Dockerfile, you can generate a compact version of your project, which will contain both the static web version of your mobile app and also the backend API:
docker build -t expo-api .
❯ docker images expo-api
REPOSITORY TAG IMAGE ID CREATED SIZE
expo-api latest a78ef09bc8c8 3 minutes ago 193MB
Running the container should be straightforward too:
❯ docker run -p 3000:3000 expo-api
Express server listening on port 3000
curl -X POST http://localhost:3000/api/echo \
-H "Content-Type: application/json" \
-d '{"hello":"world"}'
{"hello":"world"}
Cool, right?
With this approach, you can implement both the mobile app and its backend dependencies in one place, generating a nice container to be deployed anywhere. No need to create a separate project whose only purpose is to provide your backend for your mobile app.