WellAlly Logo
WellAlly康心伴
Development

Server-Side Oura Ring Data in React: A Next.js & React Query Tutorial

Learn how to integrate the Oura Ring API with Next.js and React Query. This step-by-step tutorial covers OAuth 2.0, server-side data fetching, and SSR.

W
2025-12-11
10 min read

Hook: Health tech is booming, and developers are at the forefront, building personalized wellness applications. Wearables like the Oura Ring provide a treasure trove of data—sleep, readiness, and activity—that can power these experiences. But integrating this data securely and efficiently into a web application can be a complex challenge involving authentication, server-state management, and performance optimization.

What we'll build/learn: In this tutorial, we'll build a Next.js application that securely connects to the Oura Ring API using OAuth 2.0. We will then use the power of TanStack's React Query to fetch, cache, and display a user's sleep, readiness, and activity scores. We'll focus on a server-side rendering (SSR) approach to ensure our data is fresh, secure, and loaded before the page is even sent to the client.

Prerequisites:

  • Node.js (v18 or later)
  • A foundational understanding of React and Next.js
  • An Oura Ring and an active Oura account
  • Familiarity with async/await in JavaScript

Why this matters to developers: This guide tackles a real-world problem: securely handling third-party API data in a modern web framework. You'll learn a robust pattern for managing API keys and access tokens on the server, preventing exposure to the client. By mastering React Query with SSR, you'll be able to build high-performance applications that feel incredibly responsive to the user.

Understanding the Problem

Integrating a third-party API like Oura's presents several challenges:

  • Authentication: The Oura API uses OAuth 2.0, a powerful but multi-step authentication protocol. We need a secure, server-side flow to handle the exchange of a temporary code for a long-lived access token.
  • Security: The Client Secret and user access tokens are highly sensitive. They must never be exposed on the client-side. All API calls to Oura must originate from a secure server environment.
  • Data Fetching & State Management: Fetching data introduces boilerplate. We need to manage loading states, error states, and caching to avoid redundant API calls. Doing this manually with useState and useEffect can quickly become messy.
  • User Experience: We want to avoid showing loading spinners whenever possible. Data should be present when the page loads, which is a perfect use case for Next.js's server-side rendering capabilities.

Our approach—using Next.js Route Handlers for the OAuth flow and API proxying, combined with React Query for state management and SSR—directly solves these challenges elegantly and efficiently.

Prerequisites

Before we start coding, let's get our Oura Application set up.

  1. Create an Oura Account: If you don't have one, sign up at cloud.ouraring.com.

  2. Register an OAuth Application:

    • Navigate to the API Applications section.
    • Click "Create New Application".
    • Fill in the details. For the "Redirect URIs", enter http://localhost:3000/api/auth/callback/oura. This is a crucial step for the OAuth flow.
    • Once created, you will receive a Client ID and a Client Secret. Keep these safe!
  3. Set up Environment Variables:

    • Create a file named .env.local in the root of your Next.js project.
    • Add your Oura credentials and a secret for encrypting session data.
    code
    # .env.local
    OURA_CLIENT_ID=your_client_id_here
    OURA_CLIENT_SECRET=your_client_secret_here
    NEXT_PUBLIC_OURA_CLIENT_ID=your_client_id_here
    APP_SECRET='complex-secret-for-session-encryption'
    NEXT_PUBLIC_APP_URL='http://localhost:3000'
    
    Code collapsed

    Note: We expose the Client ID publicly (NEXT_PUBLIC_) because it's needed on the client to initiate the login flow. The Client Secret remains securely on the server.

  4. Create a new Next.js App and install dependencies:

    code
    npx create-next-app@latest oura-react-query-app
    cd oura-react-query-app
    npm install @tanstack/react-query @tanstack/react-query-devtools iron-session
    
    Code collapsed
    • @tanstack/react-query: The core library for server state management.
    • @tanstack/react-query-devtools: A helpful tool for debugging React Query's cache.
    • iron-session: A library for creating secure, encrypted HTTPOnly cookies to store our session data (like the Oura access token).

Step 1: Implementing the OAuth 2.0 Flow

Our first major task is to get the user authenticated with Oura. This involves three parts: redirecting the user to Oura, handling the callback, and storing the access token securely.

What we're doing

We'll create two API routes in Next.js:

  1. /api/auth/login/oura: This route will construct the Oura authorization URL and redirect the user to it.
  2. /api/auth/callback/oura: Oura will redirect the user back here after they approve our app. This route will exchange the received code for an access_token and save it in an encrypted cookie.

Implementation

First, let's set up our session management utility.

code
// lib/session.js
import { getIronSession } from 'iron-session';

const sessionOptions = {
  password: process.env.APP_SECRET,
  cookieName: 'oura-session',
  cookieOptions: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
  },
};

export function getSession(req, res) {
  return getIronSession(req, res, sessionOptions);
}
Code collapsed

Now, for the login route. This route simply redirects the user to the Oura consent screen.

code
// app/api/auth/login/oura/route.js
import { NextResponse } from 'next/server';

export async function GET() {
  const authUrl = new URL('https://cloud.ouraring.com/oauth/authorize');
  authUrl.searchParams.set('client_id', process.env.NEXT_PUBLIC_OURA_CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/oura`);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', 'daily'); // Requesting daily sleep, activity, and readiness data.

  return NextResponse.redirect(authUrl.toString());
}
Code collapsed

Finally, the callback route. This is where the magic happens.

code
// app/api/auth/callback/oura/route.js
import { NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');

  if (!code) {
    // Handle error: No code was provided
    return NextResponse.redirect(new URL('/?error=oauth_error', request.url));
  }

  // Exchange code for access token
  const tokenResponse = await fetch('https://api.ouraring.com/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/oura`,
      client_id: process.env.OURA_CLIENT_ID,
      client_secret: process.env.OURA_CLIENT_SECRET,
    }),
  });

  if (!tokenResponse.ok) {
    // Handle error: Failed to get token
    console.error('Failed to exchange code for token:', await tokenResponse.text());
    return NextResponse.redirect(new URL('/?error=token_exchange_failed', request.url));
  }

  const tokenData = await tokenResponse.json();

  // Encrypt and store the token in a cookie
  const session = await getIronSession(request.cookies, new NextResponse().cookies, {
      password: process.env.APP_SECRET,
      cookieName: 'oura-session'
  });

  session.accessToken = tokenData.access_token;
  await session.save();

  // Redirect to the dashboard page
  return NextResponse.redirect(new URL('/dashboard', request.url));
}
Code collapsed

How it works

The OAuth 2.0 "Authorization Code" flow is a standard for secure authentication.

  1. Our app redirects the user to Oura's authorization server.
  2. The user logs into Oura and grants our application permission to access their daily data.
  3. Oura redirects the user back to our specified redirect_uri (/api/auth/callback/oura) with a temporary code.
  4. Our server-side callback handler receives this code. It then makes a secure, server-to-server POST request to Oura's /oauth/token endpoint, including our client_secret.
  5. Oura validates the request and returns an access_token.
  6. We use iron-session to store this token in an encrypted, httpOnly cookie. This means the token is securely stored and cannot be accessed by client-side JavaScript, mitigating XSS attacks.

Step 2: Fetching Oura Data with React Query

Now that we have an access token, we can fetch the user's data. We'll create another API route to act as a proxy to the Oura API and then call it from our frontend using React Query.

What we're doing

  1. Create an API route /api/oura/daily-summary that reads the access token from the session cookie and fetches data from the official Oura API.
  2. Set up a React Query QueryClientProvider in our _app.js or layout.js.
  3. Use the useQuery hook on our dashboard page to call our internal API route and display the data.

Implementation

First, the server-side API proxy. This route protects our access token.

code
// app/api/oura/daily-summary/route.js
import { NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';

export async function GET() {
  const session = await getIronSession(cookies(), {
    password: process.env.APP_SECRET,
    cookieName: 'oura-session',
  });

  if (!session.accessToken) {
    return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
  }

  // Get yesterday's date in YYYY-MM-DD format
  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  const dateStr = yesterday.toISOString().split('T');

  const ouraResponse = await fetch(
    `https://api.ouraring.com/v2/usercollection/daily_activity?start_date=${dateStr}&end_date=${dateStr}`,
    {
      headers: {
        'Authorization': `Bearer ${session.accessToken}`,
      },
    }
  );

  if (!ouraResponse.ok) {
    console.error('Oura API Error:', await ouraResponse.text());
    return NextResponse.json({ error: 'Failed to fetch data from Oura' }, { status: ouraResponse.status });
  }

  const ouraData = await ouraResponse.json();
  return NextResponse.json(ouraData);
}
Code collapsed

Next, set up the React Query provider. This makes the query client available throughout our app.

code
// app/providers.js
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export default function Providers({ children }) {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

// app/layout.js
import Providers from './providers';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
Code collapsed

Finally, let's build the dashboard page that uses useQuery to fetch and display the data.

code
// app/dashboard/page.js
'use client';
import { useQuery } from '@tanstack/react-query';

const fetchOuraData = async () => {
  const res = await fetch('/api/oura/daily-summary');
  if (!res.ok) {
    throw new Error('Network response was not ok');
  }
  return res.json();
};

export default function Dashboard() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['ouraDailySummary'],
    queryFn: fetchOuraData,
  });

  if (isLoading) {
    return <span>Loading...</span>;
  }

  if (isError) {
    return <span>Error: {error.message}</span>;
  }

  // Assuming the API returns data in the expected format
  const summary = data?.data?.;

  if (!summary) {
    return <span>No data available for yesterday.</span>
  }

  return (
    <div>
      <h1>Your Oura Daily Summary for {summary.day}</h1>
      <h2>Readiness Score: {summary.score}</h2>
      <ul>
        <li>Activity Score: {summary.contributors?.activity_balance}</li>
        <li>Sleep Score: {summary.contributors?.recovery_index}</li>
      </ul>
    </div>
  );
}
Code collapsed

How it works

useQuery is the magic of React Query. It takes a unique queryKey and a queryFn (an async function that returns our data). React Query automatically handles:

  • Caching: If we navigate away and back to this page, the data will be served from the cache instantly while a fresh fetch happens in the background.
  • Loading and Error States: isLoading and isError booleans simplify our UI logic.
  • Background Refetching: It can automatically refetch data when the window is refocused, keeping our UI in sync.

Putting It All Together with Server-Side Rendering (SSR)

The client-side fetching approach works, but the user sees a "Loading..." message. We can do better. By using Next.js's getServerSideProps and React Query's prefetching mechanism, we can fetch the data on the server and render the complete HTML.

What we're doing

We will refactor our dashboard page to use a Server Component to fetch the data initially. React Query supports this through a mechanism called "hydration". We pre-fetch on the server, "dehydrate" the cache (turn it into a serializable format), and then "rehydrate" it on the client.

Implementation

code
// app/dashboard/page.js (Updated for SSR with App Router)
import { QueryClient, dehydrate } from '@tanstack/react-query';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import Hydrate from '@/components/Hydrate'; // A simple hydration component
import DashboardClient from './DashboardClient'; // The client component part

// This is our data fetching function, now callable on the server
const fetchOuraData = async (accessToken) => {
    if (!accessToken) throw new Error('Not Authenticated');

    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    const dateStr = yesterday.toISOString().split('T');

    const ouraResponse = await fetch(
        `https://api.ouraring.com/v2/usercollection/daily_activity?start_date=${dateStr}&end_date=${dateStr}`,
        {
            headers: { 'Authorization': `Bearer ${accessToken}` },
        }
    );
    if (!ouraResponse.ok) throw new Error('Failed to fetch from Oura API');
    return ouraResponse.json();
};

export default async function DashboardPage() {
    const queryClient = new QueryClient();

    const session = await getIronSession(cookies(), {
        password: process.env.APP_SECRET,
        cookieName: 'oura-session',
    });
    
    // We prefetch the query on the server
    await queryClient.prefetchQuery({
        queryKey: ['ouraDailySummary'],
        queryFn: () => fetchOuraData(session.accessToken),
    });
    
    const dehydratedState = dehydrate(queryClient);

    return (
        <Hydrate state={dehydratedState}>
            <DashboardClient />
        </Hydrate>
    );
}

// components/Hydrate.js
'use client';
import { Hydrate as RQHydrate } from '@tanstack/react-query';

function Hydrate(props) {
  return <RQHydrate {...props} />;
}

export default Hydrate;


// app/dashboard/DashboardClient.js (This contains our previous page logic)
'use client';
import { useQuery } from '@tanstack/react-query';

// We now need a client-side fetcher for background updates, but it won't be called on initial load.
const clientFetchOuraData = async () => {
    const res = await fetch('/api/oura/daily-summary'); // uses our proxy
    if (!res.ok) {
      throw new Error('Network response was not ok');
    }
    return res.json();
};

export default function DashboardClient() {
  // This hook will now find the data in the cache immediately on the client!
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['ouraDailySummary'],
    queryFn: clientFetchOuraData, 
  });

  // This UI logic remains the same
  if (isLoading) return <span>Loading...</span>; // You won't see this on first load
  if (isError) return <span>Error: {error.message}</span>;
  
  const summary = data?.data?.;

  if (!summary) return <span>No data available for yesterday.</span>;

  return (
    <div>
      <h1>Your Oura Daily Summary for {summary.day}</h1>
      <h2>Readiness Score: {summary.score}</h2>
      <ul>
        <li>Activity Score: {summary.contributors?.activity_balance}</li>
        <li>Sleep Score: {summary.contributors?.recovery_index}</li>
      </ul>
    </div>
  );
}
Code collapsed

How it works

  1. Server-Side (DashboardPage): This is now an async Server Component. It gets the session, creates a new QueryClient, and calls queryClient.prefetchQuery. This fetches the data directly on the server.
  2. Dehydration: dehydrate(queryClient) serializes the query result and its cache metadata into JSON.
  3. Hydration: We pass this dehydrated state to a special <Hydrate> component.
  4. Client-Side (DashboardClient): When the page loads in the browser, the <QueryClientProvider> is rehydrated with this server-fetched data. The useQuery hook in DashboardClient sees that data for ['ouraDailySummary'] already exists in the cache and uses it instantly, avoiding any loading state on the initial render.

Conclusion

We have successfully built a full-featured, secure, and performant integration with the Oura Ring API in a Next.js application.

Summary of achievements:

  • We implemented the complete server-side OAuth 2.0 flow to securely authenticate users.
  • We used iron-session to store sensitive access tokens in encrypted, httpOnly cookies.
  • We created a "proxy" API route to handle all communication with the Oura API, keeping our credentials safe.
  • We leveraged React Query and useQuery for declarative data fetching and powerful state management.
  • Finally, we used Next.js server-side rendering with React Query's hydration to pre-fetch data, eliminating initial load times and improving SEO.

This powerful pattern is not limited to Oura; you can apply it to any third-party API integration, building robust and delightful user experiences.

Next steps for readers:

  • Fetch more data: Explore other Oura API endpoints like sleep periods or workouts.
  • Add a "Log Out" button: Create an API route that destroys the session.
  • Handle token refresh: The Oura access token will eventually expire. Implement logic to use the refresh_token (obtained in the initial token exchange) to get a new access token automatically.
  • Visualize the data: Use a charting library like Chart.js or D3 to create a beautiful health dashboard.

Resources

#

Article Tags

reactnextjsapihealthtechdatavisualization
W

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

Expertise

Healthcare TechnologySoftware DevelopmentUser ExperienceAI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey

© 2024 康心伴 WellAlly · Professional Health Management