Craft

Deploying a Self-Hosted Expo API Routes using Docker

Learn how combining Expo with libSQL enhances app performance and speeds up load times.Deploying a Self-Hosted Expo API Routes using Docker

Last edited: Mon Oct 28 2024

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/PATCH/DELETE 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:

terminal
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:

app.json
"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:

terminal
touch app/api/echo+api.ts

Here we just parse the body and return it as a response:

app/api/echo+api.ts
export async function POST(request: Request) {
  const message = await request.json();
  return Response.json(message);
}

Run the development server to test our new API:

terminal
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

terminal
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:

HomeScreen.tsx
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:

app.json
"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:

terminal
npm run expo export -p web

This will generate a dist directory with client and server.

terminal
npm install express compression morgan -D

Expo documentation provides a server script based on Express.js that can serve the exported project:

server.js
#!/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:

terminal
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:

Dockerfile
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:

terminal
docker build -t expo-api .
terminal
 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
terminal
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.

Authors

Younes

Share