DEV Community

Cover image for Crypto News Aggregator using Typescript, Next.js, NewsDataHub and CoinGecko APIs
olga s for NewsDataHub

Posted on

Crypto News Aggregator using Typescript, Next.js, NewsDataHub and CoinGecko APIs

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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']
});
Enter fullscreen mode Exit fullscreen mode

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

jest.setup.js:

import '@testing-library/jest-dom';
Enter fullscreen mode Exit fullscreen mode

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

"test": "jest",
"test:watch": "jest --watch"
Enter fullscreen mode Exit fullscreen mode

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"
  },
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

cp .env.example .env.local
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {};
Enter fullscreen mode Exit fullscreen mode

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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[];
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

export * from './cache';
export * from './news';
export * from './crypto';
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

app/components/news-feed/index.tsx

export { NewsCard } from './NewsCard';
Enter fullscreen mode Exit fullscreen mode

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>
);
}
Enter fullscreen mode Exit fullscreen mode

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%);
  }
}
Enter fullscreen mode Exit fullscreen mode

app/components/news-feed/index.tsx

export { PriceTicker } from './PriceTicker';
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Running your project

Go ahead and run your project

npm run dev
Enter fullscreen mode Exit fullscreen mode

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 }
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

Run the test

npm run test
Enter fullscreen mode Exit fullscreen mode

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/

Top comments (0)