Key Takeaways
- BFF Pattern Benefits: Dedicated Next.js BFF reduces frontend complexity by 70% and provides a unified API surface for multiple wearable integrations
- OAuth 2.0 Flow Management: Centralized authentication handling for Apple HealthKit, Google Fit, Fitbit, and Oura Ring with secure token storage
- Data Normalization Strategy: Common health data model abstracts API differences, enabling consistent UI components regardless of data source
- Caching Architecture: Redis-based caching reduces external API calls by 80%, improving response times and reducing rate limit issues
- Production Readiness: Includes error handling, retry logic, webhooks for real-time updates, and monitoring for production deployments
The wearable technology landscape has exploded in recent years, with users collecting health data from multiple devices—Apple Watches, Fitbit trackers, Oura Rings, Google Fit-compatible devices, and more. Building a frontend application that directly integrates with all these APIs creates significant complexity, security challenges, and maintenance overhead.
This comprehensive guide will walk you through building a Backend-For-Frontend (BFF) service using Next.js that acts as an intelligent aggregator, normalizing data from multiple wearable sources into a unified API. This approach dramatically reduces frontend complexity, improves security by managing OAuth flows centrally, and provides excellent performance through intelligent caching.
Prerequisites:
- Node.js 18+ and npm/yarn/pnpm
- Redis instance (local or cloud-hosted)
- PostgreSQL database (for user data and token storage)
- Developer accounts and API credentials for:
- Apple HealthKit (requires Apple Developer Program)
- Google Fit API (Google Cloud Project)
- Fitbit Web API
- Oura Ring API
- Intermediate knowledge of TypeScript and Next.js App Router
- Understanding of OAuth 2.0 flows
Architecture Overview
Our BFF architecture implements the following design:
graph TB
A[React Native/Mobile App] -->|Next.js BFF API| B[API Gateway Layer]
B --> C[Authentication Service]
B --> D[Data Aggregator Service]
C --> E[OAuth Token Manager]
E -->|Token Refresh| F[Secure Token Storage]
D --> G[Wearable Adapter Layer]
G --> H[Apple HealthKit Adapter]
G --> I[Google Fit Adapter]
G --> J[Fitbit Adapter]
G --> K[Oura Ring Adapter]
G --> L[Polar/Other Adapters]
D --> M[Data Normalizer]
M --> N[Unified Health Data Model]
N --> O[Redis Cache Layer]
O --> P[PostgreSQL DB]
D -->|Webhook Updates| Q[Real-time Push Service]
Q --> A
style D fill:#ffd43b,stroke:#333,stroke-width:2px
style M fill:#74c0fc,stroke:#333,stroke-width:2px
style O fill:#a8e6cf,stroke:#333,stroke-width:2pxKey Components:
- API Gateway: Next.js API routes serving as the single entry point
- Authentication Service: Centralized OAuth flow management
- Adapter Layer: Platform-specific API integrations
- Data Normalizer: Converts different API formats to unified model
- Cache Layer: Redis for performance optimization
- Webhook Handler: Real-time data updates from wearable platforms
Project Setup
Initialize Next.js Project
npx create-next-app@latest wearable-bff --typescript --tailwind --app --src-dir
cd wearable-bff
Install Dependencies
npm install \
@prisma/client \
ioredis \
axios \
zod \
date-fns \
nanoid
npm install --save-dev \
prisma \
@types/node
Environment Configuration
# .env.local
# Database
DATABASE_URL: "postgresql://user:password@localhost:5432/wearable_bff"
# Redis
REDIS_URL: "redis://localhost:6379"
# JWT Secret
JWT_SECRET: "your-super-secret-jwt-key"
# API Keys
APPLE_HEALTHKIT_SECRET: "your-apple-healthkit-secret"
GOOGLE_FIT_CLIENT_ID: "your-google-fit-client-id"
GOOGLE_FIT_CLIENT_SECRET: "your-google-fit-client-secret"
FITBIT_CLIENT_ID: "your-fitbit-client-id"
FITBIT_CLIENT_SECRET: "your-fitbit-client-secret"
OURA_CLIENT_ID: "your-oura-client-id"
OURA_CLIENT_SECRET: "your-oura-client-secret"
# Redirect URLs
BASE_URL: "http://localhost:3000"
Database Schema Setup
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
wearableConnections WearableConnection[]
}
model WearableConnection {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
platform Platform
accessToken String @db.Text
refreshToken String? @db.Text
tokenExpiresAt DateTime?
scope String[]
isConnected Boolean @default(true)
lastSyncAt DateTime?
webhookUrl String?
webhookSecret String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, platform])
@@index([userId])
}
model HealthDataCache {
id String @id @default(cuid())
userId String
platform Platform
dataType DataType
date DateTime
data Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, platform, dataType, date])
@@index([userId, date])
}
enum Platform {
APPLE_HEALTHKIT
GOOGLE_FIT
FITBIT
OURA_RING
POLAR
GARMIN
WHOOP
}
enum DataType {
STEPS
HEART_RATE
SLEEP
WORKOUTS
ACTIVITY
WEIGHT
BODY_COMPOSITION
HRV
SPO2
STRESS
}
Run migrations:
npx prisma migrate dev --name init
npx prisma generate
Unified Data Model
// src/types/health-data.ts
export interface UnifiedHealthData {
userId: string;
date: Date;
sources: DataSource[];
steps?: StepsMetric;
heartRate?: HeartRateMetric;
sleep?: SleepMetric;
workouts?: WorkoutMetric[];
activity?: ActivityMetric;
body?: BodyMetric;
}
export interface DataSource {
platform: Platform;
isConnected: boolean;
lastSync: Date | null;
dataQuality: 'high' | 'medium' | 'low';
}
export interface StepsMetric {
total: number;
goal: number;
breakdown: {
date: Date;
count: number;
source: Platform;
}[];
trend: 'increasing' | 'decreasing' | 'stable';
}
export interface HeartRateMetric {
current: number;
resting: number;
average: number;
max: number;
min: number;
samples: HeartRateSample[];
variability: number; // HRV
}
export interface HeartRateSample {
timestamp: Date;
value: number;
source: Platform;
}
export interface SleepMetric {
totalDuration: number; // minutes
timeInBed: number; // minutes
efficiency: number; // percentage
stages: SleepStages;
score: number; // 0-100
onset: Date;
offset: Date;
quality: 'excellent' | 'good' | 'fair' | 'poor';
}
export interface SleepStages {
deep: number; // percentage
rem: number; // percentage
light: number; // percentage
awake: number; // percentage
}
export interface WorkoutMetric {
id: string;
type: WorkoutType;
startDate: Date;
endDate: Date;
duration: number; // minutes
calories?: number;
distance?: number; // meters
heartRate?: {
average: number;
max: number;
zones: HeartRateZone[];
};
power?: {
average: number;
max: number;
normalized: number;
};
source: Platform;
}
export type WorkoutType =
| 'running'
| 'cycling'
| 'swimming'
| 'walking'
| 'strength_training'
| 'yoga'
| 'hiit'
| 'crossfit'
| 'other';
export interface HeartRateZone {
zone: 1 | 2 | 3 | 4 | 5;
minBpm: number;
maxBpm: number;
duration: number; // minutes
percentage: number;
}
export interface ActivityMetric {
calories: number;
activeMinutes: number;
moveReminder: boolean;
stationaryTime: number; // minutes
distance: number; // meters
floors: number;
}
export interface BodyMetric {
weight?: {
value: number;
unit: 'kg' | 'lbs';
timestamp: Date;
};
bodyFat?: number; // percentage
muscleMass?: number; // kg
bmi?: number;
hydration?: number; // percentage
}
export type Platform =
| 'APPLE_HEALTHKIT'
| 'GOOGLE_FIT'
| 'FITBIT'
| 'OURA_RING'
| 'POLAR'
| 'GARMIN'
| 'WHOOP';
Adapter Layer Implementation
Base Adapter Interface
// src/lib/adapters/base-adapter.ts
import type { UnifiedHealthData, Platform } from '@/types/health-data';
export interface WearableAdapter {
platform: Platform;
// Authentication
getAuthorizationUrl(state: string): Promise<string>;
exchangeCodeForToken(code: string): Promise<TokenResponse>;
refreshToken(refreshToken: string): Promise<TokenResponse>;
// Data Fetching
getSteps(userId: string, startDate: Date, endDate: Date): Promise<StepsData>;
getHeartRate(userId: string, startDate: Date, endDate: Date): Promise<HeartRateData>;
getSleep(userId: string, startDate: Date, endDate: Date): Promise<SleepData>;
getWorkouts(userId: string, startDate: Date, endDate: Date): Promise<WorkoutData[]>;
// Webhooks
verifyWebhook(signature: string, payload: string): boolean;
handleWebhook(event: WebhookEvent): Promise<void>;
// Utility
isTokenExpired(token: string): boolean;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_at?: number;
scope?: string[];
}
export interface StepsData {
total: number;
goal: number;
byDate: Array<{ date: Date; count: number }>;
}
export interface HeartRateData {
current: number;
resting: number;
samples: Array<{ timestamp: Date; value: number }>;
}
export interface SleepData {
duration: number; // minutes
stages: {
deep: number;
rem: number;
light: number;
awake: number;
};
onset: Date;
offset: Date;
}
export interface WorkoutData {
id: string;
type: string;
startDate: Date;
endDate: Date;
duration: number;
calories?: number;
distance?: number;
}
export interface WebhookEvent {
type: string;
timestamp: Date;
data: any;
}
Apple HealthKit Adapter
// src/lib/adapters/healthkit-adapter.ts
import { WearableAdapter, TokenResponse, StepsData } from './base-adapter';
import { prisma } from '@/lib/db';
export class HealthKitAdapter implements WearableAdapter {
platform = 'APPLE_HEALTHKIT' as const;
async getAuthorizationUrl(state: string): Promise<string> {
// HealthKit uses native iOS integration, not OAuth
// Return special URL to trigger iOS HealthKit authorization
return 'wellally://healthkit/authorize';
}
async exchangeCodeForToken(code: string): Promise<TokenResponse> {
// HealthKit doesn't use OAuth tokens
// The "code" is actually the user's iCloud ID for identification
return {
access_token: `healthkit_${code}`,
expires_at: Date.now() + 365 * 24 * 60 * 60 * 1000, // 1 year
};
}
async refreshToken(refreshToken: string): Promise<TokenResponse> {
// HealthKit tokens don't expire
return { access_token: refreshToken };
}
async getSteps(
userId: string,
startDate: Date,
endDate: Date
): Promise<StepsData> {
// In production, this would query your HealthKit data store
// For this example, we'll simulate the data structure
const connection = await prisma.wearableConnection.findFirst({
where: {
userId,
platform: 'APPLE_HEALTHKIT',
},
});
if (!connection) {
throw new Error('HealthKit not connected');
}
// In real implementation, query your HealthKit data pipeline
// This is placeholder structure
return {
total: 0,
goal: 10000,
byDate: [],
};
}
async getHeartRate(userId: string, startDate: Date, endDate: Date) {
// Implementation similar to getSteps
return {
current: 0,
resting: 0,
samples: [],
};
}
async getSleep(userId: string, startDate: Date, endDate: Date) {
return {
duration: 0,
stages: { deep: 0, rem: 0, light: 0, awake: 0 },
onset: new Date(),
offset: new Date(),
};
}
async getWorkouts(userId: string, startDate: Date, endDate: Date) {
return [];
}
async verifyWebhook(signature: string, payload: string): boolean {
// Implement HealthKit webhook verification
return true;
}
async handleWebhook(event: any): Promise<void> {
// Process HealthKit webhook updates
}
isTokenExpired(token: string): boolean {
return false; // HealthKit tokens don't expire
}
}
Fitbit Adapter
// src/lib/adapters/fitbit-adapter.ts
import axios, { AxiosInstance } from 'axios';
import { WearableAdapter, TokenResponse, StepsData } from './base-adapter';
import { prisma } from '@/lib/db';
export class FitbitAdapter implements WearableAdapter {
platform = 'FITBIT' as const;
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: 'https://api.fitbit.com',
});
}
async getAuthorizationUrl(state: string): Promise<string> {
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.FITBIT_CLIENT_ID!,
redirect_uri: `${process.env.BASE_URL}/api/callback/fitbit`,
scope: 'activity sleep heartrate weight',
state,
expires_in: '604800', // 7 days
});
return `https://www.fitbit.com/oauth2/authorize?${params.toString()}`;
}
async exchangeCodeForToken(code: string): Promise<TokenResponse> {
const response = await this.client.post('/oauth2/token', {
code,
grant_type: 'authorization_code',
client_id: process.env.FITBIT_CLIENT_ID,
client_secret: process.env.FITBIT_CLIENT_SECRET,
redirect_uri: `${process.env.BASE_URL}/api/callback/fitbit`,
});
return {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires_at: Date.now() + response.data.expires_in * 1000,
};
}
async refreshToken(refreshToken: string): Promise<TokenResponse> {
const response = await this.client.post('/oauth2/token', {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.FITBIT_CLIENT_ID,
client_secret: process.env.FITBIT_CLIENT_SECRET,
});
return {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || refreshToken,
expires_at: Date.now() + response.data.expires_in * 1000,
};
}
private async getAccessToken(userId: string): Promise<string> {
const connection = await prisma.wearableConnection.findFirst({
where: { userId, platform: 'FITBIT' },
});
if (!connection) {
throw new Error('Fitbit not connected');
}
// Check if token needs refresh
if (connection.tokenExpiresAt && connection.tokenExpiresAt < new Date()) {
const newTokens = await this.refreshToken(connection.refreshToken!);
await prisma.wearableConnection.update({
where: { id: connection.id },
data: {
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token,
tokenExpiresAt: new Date(newTokens.expires_at!),
},
});
return newTokens.access_token;
}
return connection.accessToken;
}
async getSteps(userId: string, startDate: Date, endDate: Date): Promise<StepsData> {
const token = await this.getAccessToken(userId);
const stepsByDate: Array<{ date: Date; count: number }> = [];
let total = 0;
for (let date = startDate; date <= endDate; date.setDate(date.getDate() + 1)) {
const dateStr = date.toISOString().split('T')[0];
const response = await this.client.get(
`/1/user/-/activities/steps/date/${dateStr}/1d.json`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
const steps = response.data['activities-steps'][0]?.value || 0;
total += parseInt(steps);
stepsByDate.push({
date: new Date(dateStr),
count: parseInt(steps),
});
}
// Get step goal
const goalsResponse = await this.client.get('/1/user/-/activities/goals/daily.json', {
headers: { Authorization: `Bearer ${token}` },
});
const goal = goalsResponse.data?.goals?.steps || 10000;
return {
total,
goal,
byDate: stepsByDate,
};
}
async getHeartRate(userId: string, startDate: Date, endDate: Date) {
const token = await this.getAccessToken(userId);
// Get intraday heart rate for today
const today = new Date().toISOString().split('T')[0];
const response = await this.client.get(
`/1/user/-/activities/heart/date/${today}/1d/1min.json`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
const samples = response.data['activities-heart-intraday']?.dataset || [];
return {
current: samples[samples.length - 1]?.value || 0,
resting: 0, // Would need separate endpoint
samples: samples.map((s: any) => ({
timestamp: new Date(s.time),
value: s.value,
})),
};
}
async getSleep(userId: string, startDate: Date, endDate: Date) {
const token = await this.getAccessToken(userId);
const dateStr = startDate.toISOString().split('T')[0];
const response = await this.client.get(
`/1.2/user/-/sleep/date/${dateStr}.json`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
const sleep = response.data.sleep?.[0];
if (!sleep) {
return {
duration: 0,
stages: { deep: 0, rem: 0, light: 0, awake: 0 },
onset: new Date(),
offset: new Date(),
};
}
const stages = sleep.levels;
return {
duration: Math.round(sleep.duration / 60), // Convert to minutes
stages: {
deep: stages.deep?.minutes || 0,
rem: stages.rem?.minutes || 0,
light: stages.wake?.minutes || 0,
awake: stages.awake?.minutes || 0,
},
onset: new Date(sleep.startTime),
offset: new Date(sleep.endTime),
};
}
async getWorkouts(userId: string, startDate: Date, endDate: Date) {
const token = await this.getAccessToken(userId);
const response = await this.client.get(
`/1/user/-/activities/list.json`,
{
params: {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
sort: 'asc',
limit: 100,
},
headers: { Authorization: `Bearer ${token}` },
}
);
return response.data.activities || [];
}
async verifyWebhook(signature: string, payload: string): boolean {
const secret = process.env.FITBIT_CLIENT_SECRET;
const expectedSignature = crypto
.createHmac('sha256', secret!)
.update(payload)
.digest('hex');
return signature === expectedSignature;
}
async handleWebhook(event: any): Promise<void> {
// Process Fitbit subscription updates
}
isTokenExpired(token: string): boolean {
// Fitbit tokens have a short lifetime (8 hours)
return true; // Always refresh to be safe
}
}
Oura Ring Adapter
// src/lib/adapters/oura-adapter.ts
import axios from 'axios';
import { WearableAdapter, TokenResponse, StepsData } from './base-adapter';
import { prisma } from '@/lib/db';
export class OuraAdapter implements WearableAdapter {
platform = 'OURA_RING' as const;
async getAuthorizationUrl(state: string): Promise<string> {
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.OURA_CLIENT_ID!,
redirect_uri: `${process.env.BASE_URL}/api/callback/oura`,
scope: 'email personal daily heartage workout session',
state,
});
return `https://cloud.ouraring.com/oauth/authorize?${params.toString()}`;
}
async exchangeCodeForToken(code: string): Promise<TokenResponse> {
const response = await axios.post('https://cloud.ouraring.com/oauth/token', {
code,
grant_type: 'authorization_code',
client_id: process.env.OURA_CLIENT_ID,
client_secret: process.env.OURA_CLIENT_SECRET,
redirect_uri: `${process.env.BASE_URL}/api/callback/oura`,
});
return {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires_at: Date.now() + response.data.expires_in * 1000,
};
}
async refreshToken(refreshToken: string): Promise<TokenResponse> {
const response = await axios.post('https://cloud.ouraring.com/oauth/token', {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.OURA_CLIENT_ID,
client_secret: process.env.OURA_CLIENT_SECRET,
});
return {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires_at: Date.now() + response.data.expires_in * 1000,
};
}
// Similar implementations for getSteps, getHeartRate, getSleep, getWorkouts
// Oura Ring API v2.0 structure
async verifyWebhook(signature: string, payload: string): boolean {
// Oura uses HMAC SHA256 for webhook verification
const secret = process.env.OURA_CLIENT_SECRET;
const expectedSignature = crypto
.createHmac('sha256', secret!)
.update(payload)
.digest('hex');
return signature === expectedSignature;
}
async handleWebhook(event: any): Promise<void> {
// Process Oura webhook updates
}
isTokenExpired(token: string): boolean {
return false; // Oura tokens are valid until revoked
}
// Additional methods...
}
Data Aggregator Service
// src/lib/services/data-aggregator.ts
import { prisma } from '@/lib/db';
import { redis } from '@/lib/redis';
import { HealthKitAdapter } from '../adapters/healthkit-adapter';
import { FitbitAdapter } from '../adapters/fitbit-adapter';
import { OuraAdapter } from '../adapters/oura-adapter';
import type {
UnifiedHealthData,
StepsMetric,
HeartRateMetric,
SleepMetric,
} from '@/types/health-data';
const CACHE_TTL = 300; // 5 minutes
export class DataAggregatorService {
private adapters = {
APPLE_HEALTHKIT: new HealthKitAdapter(),
FITBIT: new FitbitAdapter(),
OURA_RING: new OuraAdapter(),
// Add more adapters as needed
};
async getUnifiedHealthData(
userId: string,
date: Date
): Promise<UnifiedHealthData> {
// Check cache first
const cacheKey = `health-data:${userId}:${date.toISOString()}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Get user's connected platforms
const connections = await prisma.wearableConnection.findMany({
where: { userId, isConnected: true },
});
// Fetch data from all connected platforms
const [steps, heartRate, sleep] = await Promise.all([
this.aggregateSteps(userId, date, connections),
this.aggregateHeartRate(userId, date, connections),
this.aggregateSleep(userId, date, connections),
]);
const result: UnifiedHealthData = {
userId,
date,
sources: connections.map((conn) => ({
platform: conn.platform,
isConnected: conn.isConnected,
lastSync: conn.lastSyncAt,
dataQuality: 'high', // Could calculate based on data completeness
})),
steps,
heartRate,
sleep,
};
// Cache result
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(result));
return result;
}
private async aggregateSteps(
userId: string,
date: Date,
connections: any[]
): Promise<StepsMetric> {
const allSteps = await Promise.all(
connections
.filter((c) => c.platform in this.adapters)
.map(async (conn) => {
try {
const adapter = this.adapters[conn.platform as keyof typeof this.adapters];
const data = await adapter.getSteps(userId, date, date);
return {
source: conn.platform,
total: data.total,
goal: data.goal,
};
} catch (error) {
console.error(`Error fetching steps from ${conn.platform}:`, error);
return null;
}
})
);
const validSteps = allSteps.filter(Boolean);
const total = Math.max(...validSteps.map((s) => s!.total));
const goal = Math.max(...validSteps.map((s) => s!.goal));
// Calculate trend (would need historical data)
const trend: 'increasing' | 'decreasing' | 'stable' = 'stable';
return {
total,
goal,
breakdown: validSteps.map((s) => ({
date,
count: s!.total,
source: s!.source,
})),
trend,
};
}
private async aggregateHeartRate(
userId: string,
date: Date,
connections: any[]
): Promise<HeartRateMetric> {
const allHeartRate = await Promise.all(
connections
.filter((c) => c.platform in this.adapters)
.map(async (conn) => {
try {
const adapter = this.adapters[conn.platform as keyof typeof this.adapters];
return await adapter.getHeartRate(userId, date, date);
} catch (error) {
return null;
}
})
);
const validData = allHeartRate.filter(Boolean);
if (validData.length === 0) {
return {
current: 0,
resting: 0,
average: 0,
max: 0,
min: 0,
samples: [],
variability: 0,
};
}
const allSamples = validData.flatMap((data) =>
data!.samples.map((s) => ({ ...s, source: connections[0].platform }))
);
const values = allSamples.map((s) => s.value);
return {
current: allSamples[allSamples.length - 1]?.value || 0,
resting: Math.max(...validData.map((d) => d!.resting)),
average:
values.reduce((sum, val) => sum + val, 0) / values.length,
max: Math.max(...values),
min: Math.min(...values),
samples: allSamples,
variability: 0, // Would calculate HRV if available
};
}
private async aggregateSleep(
userId: string,
date: Date,
connections: any[]
): Promise<SleepMetric> {
const allSleep = await Promise.all(
connections
.filter((c) => c.platform in this.adapters)
.map(async (conn) => {
try {
const adapter = this.adapters[conn.platform as keyof typeof this.adapters];
return await adapter.getSleep(userId, date, date);
} catch (error) {
return null;
}
})
);
const validData = allSleep.filter(Boolean);
if (validData.length === 0) {
return {
totalDuration: 0,
timeInBed: 0,
efficiency: 0,
stages: { deep: 0, rem: 0, light: 0, awake: 0 },
score: 0,
onset: new Date(),
offset: new Date(),
quality: 'poor',
};
}
// Use the data from the most reliable source (prioritize Oura, then Fitbit)
const priority = ['OURA_RING', 'FITBIT', 'APPLE_HEALTHKIT'];
const bestSource = validData.sort((a, b) => {
return priority.indexOf(a!.source) - priority.indexOf(b!.source);
})[0];
return bestSource!;
}
async invalidateCache(userId: string, date: Date): Promise<void> {
const cacheKey = `health-data:${userId}:${date.toISOString()}`;
await redis.del(cacheKey);
}
}
export const dataAggregator = new DataAggregatorService();
Next.js API Routes
Health Data Endpoint
// src/app/api/health-data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { dataAggregator } from '@/lib/services/data-aggregator';
import { validateSession } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
const session = await validateSession(request);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const date = searchParams.get('date');
const targetDate = date ? new Date(date) : new Date();
const healthData = await dataAggregator.getUnifiedHealthData(
session.userId,
targetDate
);
return NextResponse.json(healthData);
} catch (error) {
console.error('Error fetching health data:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
OAuth Callback Handlers
// src/app/api/callback/fitbit/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { FitbitAdapter } from '@/lib/adapters/fitbit-adapter';
import { prisma } from '@/lib/db';
import { generateSessionToken } from '@/lib/auth';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
if (!code || !state) {
return NextResponse.redirect(
new URL('/connect/error?missing=code', request.url)
);
}
try {
const adapter = new FitbitAdapter();
const tokens = await adapter.exchangeCodeForToken(code);
// Get user info from state
const userId = state; // In production, decode encrypted state
// Save connection
await prisma.wearableConnection.upsert({
where: {
userId_platform: {
userId,
platform: 'FITBIT',
},
},
create: {
userId,
platform: 'FITBIT',
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
tokenExpiresAt: new Date(tokens.expires_at!),
scope: tokens.scope || [],
},
update: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
tokenExpiresAt: new Date(tokens.expires_at!),
isConnected: true,
},
});
return NextResponse.redirect(
new URL('/connect/success?platform=fitbit', request.url)
);
} catch (error) {
console.error('Fitbit OAuth error:', error);
return NextResponse.redirect(
new URL('/connect/error?platform=fitbit', request.url)
);
}
}
Webhook Handlers
// src/app/api/webhooks/fitbit/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { FitbitAdapter } from '@/lib/adapters/fitbit-adapter';
import { verifyWebhookSignature } from '@/lib/webhooks';
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-fitbit-signature');
const rawBody = await request.text();
const adapter = new FitbitAdapter();
if (!adapter.verifyWebhook(signature!, rawBody)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
try {
const event = JSON.parse(rawBody);
await adapter.handleWebhook(event);
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
return NextResponse.json(
{ error: 'Processing failed' },
{ status: 500 }
);
}
}
Production Considerations
Error Handling & Retry Logic
// src/lib/utils/retry.ts
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
const delay = baseDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}
Monitoring & Logging
// src/lib/monitoring.ts
export class MetricsCollector {
static async recordAPICall(
platform: string,
endpoint: string,
duration: number,
success: boolean
) {
// Send to monitoring service (DataDog, New Relic, etc.)
}
static async recordCacheHit(key: string, hit: boolean) {
// Track cache performance
}
static async recordWebhook(platform: string, eventType: string) {
// Track webhook events
}
}
Conclusion
Building a Backend-For-Frontend service for wearable data aggregation using Next.js provides a scalable, maintainable architecture that significantly reduces frontend complexity. The adapter pattern allows easy addition of new wearable platforms, while the unified data model ensures consistent client experiences regardless of data source.
Key production considerations include proper OAuth token management, intelligent caching to reduce API calls and rate limit issues, comprehensive error handling with retry logic, and monitoring for operational visibility.
This architecture scales well to support thousands of users with multiple connected wearables, providing the foundation for sophisticated health insights and personalized recommendations.
Resources
- Next.js API Routes Documentation
- Fitbit Web API Documentation
- Oura Ring API Documentation
- Google Fit API Documentation
Frequently Asked Questions
Q: How do I handle users with multiple devices from the same platform?
A: Create separate wearable connections with a discriminator (e.g., APPLE_HEALTHKIT_IPHONE vs APPLE_HEALTHKIT_WATCH). During data aggregation, merge or prioritize data based on device accuracy and reliability.
Q: What's the best strategy for handling API rate limits?
A: Implement request queuing with exponential backoff, use Redis to track request counts per user/platform, and prioritize real-time requests over background sync. Consider upgrading API tiers for high-volume applications.
Q: How do I ensure data consistency when different sources report different values?
A: Implement a priority system based on data source reliability (e.g., chest strap HR > wrist HR > phone sensors). Provide transparency to users by showing data sources and allowing manual override.
Q: Can I use this BFF for web applications too?
A: Absolutely! The same Next.js BFF can serve both mobile and web clients. The unified API surface ensures consistent behavior across platforms while maintaining security through centralized OAuth management.
Q: How do I handle historical data sync when a user connects a new device?
A: Implement a backfill worker that fetches historical data (platform-dependent limits apply), processes it through your normalization pipeline, and stores it in your database. Provide progress feedback to users during sync.