Tutorial: Crypto News Aggregator Using Typescript Nextjs Newsdatahub and Coingecko Apis
Posted on | 2331 words | ~11mins

crypto

In this article, we’re going to walk through building a simple but useful cryptocurrency news aggregator app that uses NewsDataHub and CoinGecko APIs. This article is aimed at a beginner-level developer—feel free to skip any sections if you think they don’t add value to your learning experience.

You can also see what the final project code looks like here: https://github.com/newsdatahub/crypto-news-aggregator

You can see what the production version of this app looks like right here: https://newsdatahub.com/crypto

Let’s start off by by creating a fresh Next.js project with Typescript support.

npx create-next-app@latest crypto-news-aggregator --typescript

When prompted, select:

  • Yes for ESLint
  • No for Tailwind CSS (we’ll use CSS modules)
  • No for src/ directory
  • Yes for App Router
  • No for Turbopack
  • No for customize import alias (we’ll set this up manually)

cd into the project’s folder:

cd crypto-news-aggregator

Project Structure Setup

After initialization, let’s create our project structure. I’ll explain the purpose of each directory and file.

mkdir -p app/components/{news-feed,price-ticker} __tests__ types

At the end of this tutorial you should end up with the following structure.

crypto-news-aggregator/
├── __tests__/                    # Test files
│   ├── Home.test.tsx
│   ├── NewsCard.test.tsx
│   └── PriceTicker.test.tsx
├── app/                          # Next.js app directory
│   ├── components/               # React components
│   │   ├── news-feed/            # News-related components
│   │   │   ├── NewsCard.tsx
│   │   │   └── index.ts
│   │   └── price-ticker/         # Price ticker components
│   │       ├── PriceTicker.tsx
│   │       └── index.ts
│   ├── layout.tsx               # Root layout component
│   ├── page.module.css          # Styles for main page
│   └── page.tsx                 # Main page component
├── public/                      # Static assets
├── types/                       # TypeScript type definitions
│   ├── cache.ts
│   ├── crypto.ts
│   ├── env.d.ts
│   ├── index.ts
│ 	└── news.ts
├── .env.example                # Example environment variables
├── .env.local                  # Environment variables (gitignored)
├── .eslintrc.json              # ESLint configuration
├── .gitignore                  # Git ignore rules
├── eslint.config.mjs           # ESLint module configuration
├── jest.config.mjs             # Jest configuration
├── jest.setup.js               # Jest setup file
├── next-env.d.ts               # Next.js TypeScript declarations
├── next.config.js              # Next.js configuration
├── package-lock.json           # Locked dependency versions
├── package.json                # Project dependencies
├── README.md
├── tsconfig.json               # TypeScript configuration
└── types.d.ts                  # Global TypeScript declarations

But before we get there we are going to need to clean up the project directory a little bit and then create some files.

The files that can be safely deleted

  • app/globals.css (if you’re using module.css files)
  • all .svg files (in /public directory)
  • README.md (delete or update, since this is the default one from create-next-app)

If your favicon.ico is in the app directory, consider moving it to the public folder. While the favicon can work in both locations, moving it to public/ follows conventional structure and makes asset locations more explicit.

Testing

We need to install several testing packages

npm install --save-dev @testing-library/react @testing-library/jest-dom jest jest-environment-jsdom

Let’s understand what each package does:

  • @testing-library/react: Provides utilities for testing React components
  • @testing-library/jest-dom: Adds custom Jest matchers
  • jest: The main testing framework
  • jest-environment-jsdom: Simulates a browser environment for our tests

Create types.d.ts for testing type definitions

import '@testing-library/jest-dom';
declare global {
  namespace jest {
    interface Matchers<R> {
      toBeInTheDocument(): R;
    }
  }
  interface Window {
    fetch: jest.Mock;
  }
}
export {};

Now let’s install TypeScript type definitions so that our code editor can understand Node.js, React, and Jest APIs, enabling autocomplete and catching type errors during development.

npm install --save-dev @types/node @types/react @types/jest

After installing the packages, we need to configure Jest. Create a jest.config.mjs file in your project root.

jest.config.mjs:

import nextJest from 'next/jest.js';

const createJestConfig = nextJest({
  dir: './',
});

export default createJestConfig({
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
});

Create a jest.setup.js file to import the DOM matchers.

jest.setup.js:

import '@testing-library/jest-dom';

Finally, add these lines to run you test script to your package.json under scripts:

"test": "jest",
"test:watch": "jest --watch"

So it would look like this:

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest",
    "test:watch": "jest --watch"
  },

Now you can run tests using npm test or npm run test:watch for watch mode. But we don’t have any tests just yet, we will add a test shortly.

Getting Your NewsDataHub API Token

Let’s walk through the process of getting your API token.

Visit NewsDataHub.com

Create Your Account (no credit card required)

  • Enter your email address into the sign-up form
  • You will need to check your email for the verification code
  • Once you verify your account, you will be taken to your dashboard where you can find your API key

Adding the API key to Your Project

Create a .env.example file in your project root to serve as a template for required environment variables

NEXT_PUBLIC_API_URL=https://api.newsdatahub.com/v1/news
NEXT_PUBLIC_API_TOKEN=your_token_here

Then run the following to copy .env.example template into .env.local where your actual configuration will be

cp .env.example .env.local

Replace your_token_here in .env.local with your NewsDataHub API token from your dashboard.

.env.example is committed to git as a template, while .env.local contains actual secrets and is gitignored.

We are going to use words “token” and “key” interchangeably when referring to the API key. Once you have your API token, add it to your project’s .env.local file:

NEXT_PUBLIC_API_URL=https://api.newsdatahub.com/v1
NEXT_PUBLIC_API_TOKEN=your_newsdatahub_token

Configuration Files Setup and Overview

Let’s set up essential configuration files. I’ll explain each one’s purpose and content:

Updating .gitignore

The .gitignore file was automatically created during project initialization. It tells Git which files and folders to exclude from version control.

Let’s make sure our .env.local file is ignored by adding the following to .gitignore.

# Environment files
.env*.local

Setting up Environment Type Definitions

Create types/env.d.ts to provide TypeScript type definitions for our environment variables:

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NEXT_PUBLIC_API_URL: string;
      NEXT_PUBLIC_API_TOKEN: string;
    }
  }
}

export {};

This file tells TypeScript about our environment variables, enabling proper type checking when accessing process.env values and providing autocomplete suggestions. Without it, TypeScript would consider these variables to be of type any .

ESLint Setup

Add .eslintrc.json to enable Next.js’s default linting rules for performance and best practices.

{
  "extends": [
    "next/core-web-vitals"
  ]
}

Now let’s add the project code

Create types/cache.ts.
Defines the structure for our client-side caching system, specifying how we store timestamps and news data

import { NewsItem } from ".";

export interface CacheData {
    timestamp: number;
    data: NewsItem[];
}

Create types/crypto.ts. Defines cryptocurrency price data structure from the CoinCap API, including price, market cap, and 24h changes.

export type CoinData = {
  [key: string]: {
    usd: number;
    usd_market_cap: number;
    usd_24h_vol: number;
    usd_24h_change: number;
    last_updated_at: number;
  }
}

Create news.ts Contains interfaces for news items from NewsDataHub API and props for our NewsCard component.

export interface NewsItem {
    id: string;
    title: string;
    article_link: string;
    description: string;
    pub_date: string;
  }

export interface NewsCardProps {
    index: number;
    item: NewsItem;
}

Create types/index.ts. Central export point for all type definitions, enabling clean imports.

export * from './cache';
export * from './news';
export * from './crypto';

NewsCard and PriceTicker Component Implementation

Next, we implement our components and their styles. Each component should be in its respective directory.

NewsCard component

app/components/news-feed/NewsCard.tsx

import styles from './styles.module.css';
import { NewsCardProps } from '@/types';

export const NewsCard: React.FC<NewsCardProps> = ({index, item}) => {
    return (
        <div key={index} className={styles.newsCard}>
        <h2 className={styles.newsTitle}>{item.title}</h2>
        <p>{item.description.slice(0, 200)+"..."} Read more 
        <br/>
        <br/>
            <a href={item.article_link} target="_blank"  rel="noopener noreferrer" className={styles.newsLink}>
            {item.article_link}
            </a>    
        </p>

        <div className={styles.newsDate}>
          {new Date(item.pub_date).toLocaleDateString()}
        </div>
      </div>
    )
}

app/components/news-feed/styles.module.css

.newsCard {
  padding: 15px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-bottom: 15px;
}

.newsTitle {
  margin: 0 0 10px 0;
  font-size: 1.2em;
  color: #2e009a;
  font-family: math;
}

.newsDate {
  color: #666;
  font-size: 0.9em;
  margin-top: 10px;
}

.newsLink {
  color:darkcyan;
}

.newsLink:hover {
  color: rgb(3, 79, 79);
  cursor: pointer;
  text-decoration: underline;
}

app/components/news-feed/index.tsx

export { NewsCard } from './NewsCard';

PriceTicker Component

app/components/price-ticker/PriceTicker.tsx

import { useState, useEffect } from 'react';
import styles from './styles.module.css';
import { CoinData } from '@/types';

export const  PriceTicker = () => {
  const [prices, setPrices] = useState<CoinData>({});
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  const fetchPrices = async () => {
    try {
      const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,dogecoin&vs_currencies=usd&include_24hr_change=true');
      if (!response.ok) throw new Error('Failed to fetch prices');
      
      const data = await response.json();

      setPrices(data);
      setError(null);
    } catch (err) {
      setError('Failed to load prices');
      console.error('Price fetch error:', err);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchPrices();
    const interval = setInterval(fetchPrices, 60000); // Update every minute
    return () => clearInterval(interval);
  }, []);

  if (loading) return <div className={styles.ticker}>Loading prices...</div>;
  if (error) return <div className={styles.ticker}>Price data unavailable</div>;

  return (
    <div className={styles.ticker}>
      {Object.entries(prices).map(([coinId, data]) => {
        const price = data.usd.toLocaleString('en-US', {
          style: 'currency',
          currency: 'USD',
          minimumFractionDigits: 2,
          maximumFractionDigits: 2
        });

        const change = data.usd_24h_change || 0;
        const changeClass = change >= 0 ? styles.positive : styles.negative;

        return (
            <div key={coinId} className={styles.cryptoPrice}>
              <span className={styles.symbol}>{coinId.toUpperCase()}</span>
              <span className={styles.price}>{price}</span>
              <span className={`${styles.change} ${changeClass}`}>
       {change >= 0 ? '↑' : '↓'}
                {Math.abs(change).toFixed(2)}%
     </span>
            </div>
        );
      })}
    </div>
);
}

app/components/price-ticker/styles.module.css

.ticker {
  background: #1a1a1a;
  color: white;
  padding: 10px;
  border-radius: 8px;
  margin-bottom: 20px;
  overflow-x: auto;
  display: flex;
  gap: 20px;
  align-items: center;
}

.cryptoPrice {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 8px;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 4px;
  white-space: nowrap;
}

.symbol {
  font-weight: bold;
  color: #ffd700;
}

.price {
  font-family: monospace;
}

.change {
  font-size: 0.9em;
  padding: 2px 6px;
  border-radius: 4px;
}

.positive {
  color: #00ff00;
}

.negative {
  color: #ff4444;
}

@keyframes slide {
  0% {
    transform: translateX(100%);
  }
  100% {
    transform: translateX(-100%);
  }
}

app/components/news-feed/index.tsx

export { PriceTicker } from './PriceTicker';

Building the Main Page Component

In Next.js App Router, the main page of our application lives in app/page.tsx. While the file is going to be named page.tsx following Next.js conventions, we name our component Home to clearly indicate its purpose as our application’s home page.

Go ahead and update app/components/page.tsx with the following code

'use client';

import { useState, useEffect } from 'react';
import { PriceTicker } from '@/app/components/price-ticker';
import { NewsCard } from '@/app/components/news-feed';
import { CacheData, NewsItem } from '@/types';
import styles from './page.module.css';

// Environment variables for API configuration
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN;

// Cache duration set to one hour
const CACHE_DURATION = 1000 * 60 * 60;
const TOPICS = ['cryptocurrency'];

// In-memory cache for storing news data
const cache: Record<string, CacheData> = {};

export default function Home() {
  // State management using React hooks
  const [news, setNews] = useState<NewsItem[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);

  // Fetches news data with built-in caching
  const fetchNews = async (topics: string[]) => {
    const cacheKey = topics.sort().join(',');
    const cachedData = cache[cacheKey];
    
    if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) {
      setNews(cachedData.data);
      setLastUpdated(new Date(cachedData.timestamp));
      return;
    }

    setLoading(true);
    setError(null);

    try {
      const response = await fetch(`${API_URL}?language=en&topic=cryptocurrency`, {
        headers: {
          'x-api-key': API_TOKEN,
          'Content-Type': 'application/json'
        },
      });

      if (!response.ok) throw new Error('Failed to fetch news');

      const articles = await response.json();
      const data: NewsItem[] = articles.data;
      
      cache[cacheKey] = {
        timestamp: Date.now(),
        data
      };
      
      setNews(data);
      setLastUpdated(new Date());
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred');
    } finally {
      setLoading(false);
    }
  };

  // Fetch data when component mounts
  useEffect(() => {
    fetchNews(TOPICS);
  }, []);

  // Handler for manual refresh
  const handleRefresh = () => {
    const cacheKey = TOPICS.sort().join(',');
    delete cache[cacheKey];
    fetchNews(TOPICS);
  };

  return (
    <div className={styles.container}>
      <PriceTicker />
      
      {lastUpdated && (
        <div className={styles.lastUpdated}>
          Last updated: {lastUpdated.toLocaleTimeString()}
          <button onClick={handleRefresh} className={styles.refreshButton}>
            Refresh
          </button>
        </div>
      )}

      {error && <div className={styles.error}>{error}</div>}
      
      {loading ? (
        <div className={styles.loading}>Loading...</div>
      ) : (
        news.map((item: NewsItem, index: number) => (
          <NewsCard key={item.id || index} item={item} index={index} />
        ))
      )}
    </div>
  );
}

Update page.module.css with the following styles:

.container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.topics {
  margin-bottom: 20px;
}

.topic {
  margin-right: 10px;
  padding: 5px 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  background: none;
  cursor: pointer;
}

.topicSelected {
  background: #007bff;
  color: white;
  border-color: #007bff;
}

.error {
  color: #dc3545;
  padding: 10px;
  border: 1px solid #dc3545;
  border-radius: 4px;
  margin-bottom: 15px;
}

.loading {
  text-align: center;
  padding: 20px;
}

.lastUpdated {
  color: #666;
  font-size: 0.9em;
  margin-bottom: 15px;
}

.refreshButton {
  background: #007bff;
  color: white;
  border: none;
  padding: 5px 10px;
  border-radius: 4px;
  cursor: pointer;
  margin-left: 10px;
}

Update app/layout.tsx with the following:

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head>
        <title>Crypto News Aggregator Application</title>
        <meta name="description" content="Crypto News Aggregator Application" />
      </head>
      <body>
        {children}
      </body>
    </html>
  )
}

Running your project

Go ahead and run your project

npm run dev

You can find your running app at https://localhost:3000

Congrats on finishing the project! 🏆 👏

Testing the Page Component

We are going to add a test for the Home component that verifies that it correctly renders news content after fetching data. Go ahead and create this test file.

__tests__/Home.test.tsx


import { render, screen, waitFor } from '@testing-library/react';
import Home from '@/app/page';

describe('Home', () => {
  beforeEach(() => {
    // Set up test environment variables
    process.env.NEXT_PUBLIC_API_URL = 'http://test-api.com';
    process.env.NEXT_PUBLIC_API_TOKEN = 'test-token';
    
    // Mock fetch for both API endpoints
    global.fetch = jest.fn((url) => {
      // Mock responses for different API calls
      if (url.includes('api.coingecko.com')) {
        return Promise.resolve({
          ok: true,
          json: () => Promise.resolve({
            bitcoin: { usd: 65000, usd_24h_change: 2.5 }
          }),
          status: 200,
        } as Response);
      }
      return Promise.resolve({
        ok: true,
        json: () => Promise.resolve({
          data: [{
            id: '1',
            title: 'News Title',
            description: 'News Description',
            url: 'https://test.com',
            published_at: '2024-03-25'
          }]
        }),
        status: 200,
      } as Response);
    }) as jest.Mock;
  });

  test('renders news feed', async () => {
    render(<Home />);
    await waitFor(
      () => expect(screen.getByText("News Title")).toBeInTheDocument(),
      { timeout: 3000 }
    );
  });
});

Run the test

npm run test

This test verifies that our Home component successfully renders news content after fetching data.

Additional tests for the PriceTicker and NewsCard components can be found in the project’s GitHub repository. These tests cover basic component-specific functionality and rendering behavior. I encourage you to create more tests for this project.

Consider improving this project further.

Some ideas:

  • Implement proper loading states
  • Add pagination for news items
  • Implement more sophisticated caching
  • Enhance the testing suite
  • You can change the topic query param to fetch different types of news

Thanks for following along! 😄

Cover image credit: Photo by RDNE Stock project: https://www.pexels.com/photo/selective-focus-photo-of-silver-and-gold-bitcoins-8369648/