WellAlly Logo
WellAlly康心伴
Development

Building a Wearable Data Aggregator with Next.js BFF Pattern

Learn how to build a scalable Backend-For-Frontend service using Next.js that aggregates health data from multiple wearable sources including Apple HealthKit, Google Fit, Fitbit, and Oura Ring APIs.

W
2026-03-08
14 min read

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:

Rendering diagram...
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:2px

Key Components:

  1. API Gateway: Next.js API routes serving as the single entry point
  2. Authentication Service: Centralized OAuth flow management
  3. Adapter Layer: Platform-specific API integrations
  4. Data Normalizer: Converts different API formats to unified model
  5. Cache Layer: Redis for performance optimization
  6. Webhook Handler: Real-time data updates from wearable platforms

Project Setup

Initialize Next.js Project

code
npx create-next-app@latest wearable-bff --typescript --tailwind --app --src-dir
cd wearable-bff
Code collapsed

Install Dependencies

code
npm install \
  @prisma/client \
  ioredis \
  axios \
  zod \
  date-fns \
  nanoid

npm install --save-dev \
  prisma \
  @types/node
Code collapsed

Environment Configuration

code
# .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"
Code collapsed

Database Schema Setup

code
// 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
}
Code collapsed

Run migrations:

code
npx prisma migrate dev --name init
npx prisma generate
Code collapsed

Unified Data Model

code
// 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';
Code collapsed

Adapter Layer Implementation

Base Adapter Interface

code
// 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;
}
Code collapsed

Apple HealthKit Adapter

code
// 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
  }
}
Code collapsed

Fitbit Adapter

code
// 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
  }
}
Code collapsed

Oura Ring Adapter

code
// 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...
}
Code collapsed

Data Aggregator Service

code
// 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();
Code collapsed

Next.js API Routes

Health Data Endpoint

code
// 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 }
    );
  }
}
Code collapsed

OAuth Callback Handlers

code
// 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)
    );
  }
}
Code collapsed

Webhook Handlers

code
// 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 }
    );
  }
}
Code collapsed

Production Considerations

Error Handling & Retry Logic

code
// 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');
}
Code collapsed

Monitoring & Logging

code
// 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
  }
}
Code collapsed

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

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.

#

Article Tags

nextjs
wearables
bff
api
healthtech
typescript
W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare Technology
Software Development
User Experience
AI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey