Fortnite has run more than 40 seasons since August 2017, and the numbering scheme alone trips people up: Chapter, Season, sub-season, OG rerun, mini-season. As of September 23, 2026, the game sits in Chapter 7, Season 4, subtitled “Override,” which began August 20, 2026, and is scheduled to close around October 31, 2026. If you have ever tried to answer “when did Chapter 4 start” or “how long do seasons usually run,” you already know the pain of chasing that answer across five different fan wikis that disagree by a day or two.

This tutorial walks through building your own Fortnite season tracker: a small Node.js project that stores chapter and season history in a local database, pulls the live current season from a public API, calculates days remaining, and exports a clean timeline you can drop into a spreadsheet or a simple web page. You will end up with a reusable tool instead of a bookmark folder full of listicles that go stale the moment Epic ships a new update.

Why build a Fortnite season tracker instead of using a wiki

Community trackers are useful, but they are manually maintained, which means dates lag, get typo’d, or conflict across sources. During research for this piece, three separate trackers listed three different end dates for the current season, off by a day in each direction, because of timezone conversion and because Epic sometimes pushes a season’s actual close date after announcing a tentative one. A script that pulls from a machine-readable source and timestamps its own updates avoids that drift.

There is also a practical reason to own the data yourself. Once you have chapter and season history in a database, you can build anything on top of it: a Discord bot that announces “12 days left in Override,” a spreadsheet that tracks Battle Pass value per season, or a static page that renders a full visual timeline. This guide builds the foundation, then shows two of those extensions.

Prerequisites and versions

You do not need much to follow along. Everything here runs locally and the total cost is $0.

  • Node.js 20 LTS or newer (ships with a native fetch, so you skip an extra HTTP package)
  • npm 10.x (bundled with Node 20)
  • A code editor (VS Code works fine, but any editor is fine)
  • A terminal (macOS Terminal, Windows Terminal, or a Linux shell)
  • A free API key from Fortnite-API.com, the community-run REST API this tutorial uses for live season data
  • Roughly 45-60 minutes for the full build

Check your Node version before starting. Anything below Node 18 lacks the built-in fetch client this tutorial relies on.

node --version
npm --version

Step 1: Understand the data model before you write code

The biggest mistake people make when tracking Fortnite seasons is cramming everything into one flat table: name, start date, end date, theme, done. That breaks the moment you want to track a mid-season map change or a Battle Pass price update, because those things happen on their own schedule inside a season, not at its start.

Instead, split the data into four related entities. A chapter contains multiple seasons. A season belongs to one chapter and has one Battle Pass. Map changes and live events are dated records tied to a season, not baked into the season row itself. This mirrors how Epic actually ships content: a season starts, then patches roll out mid-season that shift the map, add mechanics, or run a live event, all before the season officially ends.

  • chapters: number, name, start date, end date, status
  • seasons: chapter reference, season number, name, start date, end date, date confidence, theme
  • battle_passes: season reference, price in V-Bucks, notable rewards
  • map_changes: season reference, effective date, location, change type, description

Note the date confidence field on seasons. Fan sites frequently publish a scheduled end date that later shifts. Storing whether a date is confirmed, scheduled, or estimated keeps your tracker honest instead of pretending every date is final the day you write it.

Step 2: Get a Fortnite-API.com key

Fortnite-API.com is a community-maintained REST API that exposes cosmetics, shop data, news, and item metadata, including the chapter and season a cosmetic was introduced in. Its documented rate limit sits at roughly 3 requests per second and 180 requests per minute on its client endpoints, according to the project’s own API documentation. That is generous enough for a tracker that refreshes a few times a day.

Sign up on the Fortnite-API.com site and generate a key from your account dashboard. Store it as an environment variable rather than hardcoding it into a script you might commit to a public repo.

# macOS/Linux
export FORTNITE_API_KEY="your-key-here"

# Windows PowerShell
setx FORTNITE_API_KEY "your-key-here"

If you would rather not manage a key at all, some endpoints on Fortnite-API.com work without authentication at a lower rate limit, which is enough for occasional manual refreshes while you are building and testing.

Step 3: Scaffold the project

Create a project folder and initialize it with npm. Keep the folder structure flat and predictable so future-you can find things fast.

mkdir fortnite-season-tracker
cd fortnite-season-tracker
npm init -y
mkdir src data
touch src/db.js src/seed.js src/fetchCurrent.js src/timeline.js src/cli.js src/exportCsv.js
touch data/seasons.seed.json

Open package.json and set the module type to ES modules so you can use modern import syntax without a bundler.

{
  "name": "fortnite-season-tracker",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "seed": "node src/seed.js",
    "refresh": "node src/fetchCurrent.js",
    "cli": "node src/cli.js",
    "export": "node src/exportCsv.js"
  }
}

Step 4: Install dependencies

You need exactly one third-party package: better-sqlite3, a fast synchronous SQLite driver that keeps the code simple since a season tracker does not need async database calls for a dataset this small. Everything else (HTTP requests, JSON handling) is built into modern Node.js.

npm install better-sqlite3

SQLite is the right call here over a hosted database. The dataset is a few hundred rows at most, spanning nine years of Fortnite history, and a single file you can commit, back up, or hand to another script beats running a database server for a hobby project. Read more about SQLite’s design goals on the official SQLite site if you want the longer case for it.

Step 5: Build the database schema

Write src/db.js to open (or create) the SQLite file and set up the four tables from Step 1. This file gets imported by every other script, so it is the one place your schema lives.

// src/db.js
import Database from 'better-sqlite3';

export const db = new Database('fortnite-seasons.db');
db.pragma('journal_mode = WAL');

db.exec(`
CREATE TABLE IF NOT EXISTS chapters (
  id INTEGER PRIMARY KEY,
  number INTEGER NOT NULL UNIQUE,
  start_date TEXT,
  end_date TEXT
);

CREATE TABLE IF NOT EXISTS seasons (
  id INTEGER PRIMARY KEY,
  chapter_number INTEGER NOT NULL,
  season_number TEXT NOT NULL,
  name TEXT NOT NULL,
  start_date TEXT NOT NULL,
  end_date TEXT,
  date_status TEXT DEFAULT 'confirmed',
  theme TEXT,
  duration_days INTEGER,
  source_url TEXT,
  last_verified_at TEXT,
  UNIQUE(chapter_number, season_number)
);

CREATE TABLE IF NOT EXISTS battle_passes (
  id INTEGER PRIMARY KEY,
  season_id INTEGER NOT NULL REFERENCES seasons(id),
  price_vbucks INTEGER,
  notable_rewards TEXT
);

CREATE TABLE IF NOT EXISTS map_changes (
  id INTEGER PRIMARY KEY,
  season_id INTEGER NOT NULL REFERENCES seasons(id),
  effective_date TEXT,
  location_name TEXT,
  change_type TEXT,
  description TEXT
);
`);

Running this file creates fortnite-seasons.db in your project root the first time any script imports it. The UNIQUE(chapter_number, season_number) constraint on seasons matters later, because it lets you upsert live API data without creating duplicate rows every time you refresh.

Step 6: Seed historical season data

No public API hands you the full, clean history of every Fortnite season back to 2017 in one call. You backfill it once from documented sources, then let the live fetch in Step 7 keep the current season fresh. Below is a representative seed drawn from confirmed dates across Fortnite’s history, from launch through the current season. Extend this list with the rest of the seasons from a source like the Fortnite seasonal events archive on Wikipedia if you want full coverage back to day one.

ChapterSeasonNameStart dateEnd dateDuration
11First Steps2017-10-262017-12-13~48 days
12Fort Knights2017-12-142018-02-21~69 days
15Worlds Collide2018-07-122018-09-27~77 days
16Darkness Rises2018-09-272018-12-06~70 days
1XOut of Time2019-08-012019-10-13~74 days
21New World2019-10-152020-02-20~128 days
31Flipped2021-12-052022-03-19~104 days
43Wilds2023-06-092023-08-25~76 days
51Underground2023-12-032024-03-08~97 days
54Absolute Doom2024-08-162024-11-02~78 days
71Pacific Break2025-11-292026-03-04~95 days
73Runners2026-06-062026-08-20~75 days
74Override2026-08-202026-10-31~74 days (scheduled)

Chapter 5 Season 1, Underground, is worth flagging in your own tracker with extra metadata beyond dates, because it is the season that launched LEGO Fortnite, Fortnite Festival, and Rocket Racing as permanent modes rather than limited-time content. That is exactly the kind of detail a flat “name and date” list loses and a properly normalized database preserves.

Put this seed data in data/seasons.seed.json as an array of objects, then write src/seed.js to load it into the database.

// data/seasons.seed.json (excerpt)
[
  {
    "chapter_number": 7,
    "season_number": "3",
    "name": "Runners",
    "start_date": "2026-06-06",
    "end_date": "2026-08-20",
    "date_status": "confirmed",
    "theme": "Speed and momentum",
    "source_url": "https://www.hotspawn.com/fortnite/guide/all-fortnite-seasons"
  },
  {
    "chapter_number": 7,
    "season_number": "4",
    "name": "Override",
    "start_date": "2026-08-20",
    "end_date": "2026-10-31",
    "date_status": "scheduled",
    "theme": "Video games and gaming legends",
    "source_url": "https://ggseason.com/blog/fortnite-all-seasons-dates/"
  }
]
// src/seed.js
import { db } from './db.js';
import { readFileSync } from 'node:fs';

const seasons = JSON.parse(readFileSync('data/seasons.seed.json', 'utf-8'));

const insert = db.prepare(`
  INSERT INTO seasons (chapter_number, season_number, name, start_date, end_date, date_status, theme, source_url, last_verified_at)
  VALUES (@chapter_number, @season_number, @name, @start_date, @end_date, @date_status, @theme, @source_url, @now)
  ON CONFLICT(chapter_number, season_number) DO UPDATE SET
    name=excluded.name, end_date=excluded.end_date, date_status=excluded.date_status,
    theme=excluded.theme, last_verified_at=excluded.last_verified_at
`);

const now = new Date().toISOString();
const insertMany = db.transaction((rows) => {
  for (const row of rows) insert.run({ ...row, now });
});

insertMany(seasons);
console.log(`Seeded ${seasons.length} seasons.`);

Run it once with npm run seed. You now have a queryable local database with real Fortnite season history in it.

Step 7: Write the live fetch script

This is the piece that keeps your tracker from going stale. Fortnite-API.com’s news and cosmetics endpoints surface the current chapter and season through item introduction metadata. A typical cosmetic record looks like this, per the project’s public schema.

{
  "id": "CID_9999_Athena_Commando",
  "name": "Example Outfit",
  "type": { "value": "outfit", "displayValue": "Outfit" },
  "rarity": { "value": "epic", "displayValue": "Epic" },
  "introduction": { "chapter": "Chapter 7", "season": "Season 4" },
  "images": { "icon": "https://fortnite-api.com/images/cosmetics/..." }
}

Write src/fetchCurrent.js to call the API, pull the newest cosmetics batch, and read the chapter and season off the first result. Wrap the request with a timeout and respect the documented rate limit by only calling this on a schedule, not in a tight loop.

// src/fetchCurrent.js
import { db } from './db.js';

const API_BASE = 'https://fortnite-api.com/v2';
const API_KEY = process.env.FORTNITE_API_KEY;

async function fetchLatestCosmetics() {
  const res = await fetch(`${API_BASE}/cosmetics/new`, {
    headers: API_KEY ? { Authorization: API_KEY } : {},
    signal: AbortSignal.timeout(8000)
  });

  if (!res.ok) {
    throw new Error(`Fortnite-API request failed: ${res.status} ${res.statusText}`);
  }

  const body = await res.json();
  return body.data;
}

function upsertCurrentSeason(chapterLabel, seasonLabel) {
  const chapterNumber = parseInt(chapterLabel.replace(/\D/g, ''), 10);
  const seasonNumber = seasonLabel.replace(/season/i, '').trim();

  const stmt = db.prepare(`
    UPDATE seasons SET date_status = 'confirmed', last_verified_at = @now
    WHERE chapter_number = @chapterNumber AND season_number = @seasonNumber
  `);
  const result = stmt.run({ chapterNumber, seasonNumber, now: new Date().toISOString() });

  if (result.changes === 0) {
    console.warn(`No local row for Chapter ${chapterNumber} Season ${seasonNumber}. Add it to your seed data.`);
  } else {
    console.log(`Confirmed Chapter ${chapterNumber} Season ${seasonNumber} as current.`);
  }
}

const cosmetics = await fetchLatestCosmetics();
const sample = cosmetics.find((item) => item.introduction);

if (sample) {
  upsertCurrentSeason(sample.introduction.chapter, sample.introduction.season);
} else {
  console.warn('No introduction metadata found in the latest cosmetics batch.');
}

Run npm run refresh. If your seed data already has a row for the current chapter and season, it flips that row’s status to confirmed and stamps it with the current timestamp. If not, the script tells you exactly what to add, instead of silently failing.

Step 8: Handle new seasons the fetch script cannot fully describe

An API built around cosmetics can confirm which chapter and season is active, but it rarely hands you a clean season name, start date, and theme in one payload the moment a season launches. Treat the fetch script as a confirmation signal, not a full data source. When it flags a season your database does not know about yet, that is your cue to add a proper row by hand, sourced from Epic’s own patch notes or a well-maintained wiki, then let the script keep that row fresh going forward.

This two-tier approach, manual entry for new seasons plus automated confirmation for existing ones, is the same pattern production data pipelines use for any fast-moving source. Humans handle judgment calls. Scripts handle repetition.

Step 9: Build the timeline and duration logic

With data in place, add the calculations people actually search for: how long a season ran, and how many days are left in the current one. Write src/timeline.js as a shared module other scripts can import.

// src/timeline.js
import { db } from './db.js';

const DAY_MS = 1000 * 60 * 60 * 24;

export function getAllSeasons() {
  return db.prepare(`
    SELECT chapter_number, season_number, name, start_date, end_date, date_status
    FROM seasons ORDER BY start_date ASC
  `).all();
}

export function withDurations(seasons) {
  return seasons.map((s) => {
    const start = new Date(s.start_date);
    const end = s.end_date ? new Date(s.end_date) : null;
    const durationDays = end ? Math.round((end - start) / DAY_MS) : null;
    return { ...s, duration_days: durationDays };
  });
}

export function getCurrentSeason() {
  const today = new Date().toISOString().slice(0, 10);
  return db.prepare(`
    SELECT * FROM seasons
    WHERE start_date <= @today AND (end_date IS NULL OR end_date >= @today)
    ORDER BY start_date DESC LIMIT 1
  `).get({ today });
}

export function daysRemaining(season) {
  if (!season?.end_date) return null;
  const end = new Date(season.end_date);
  const today = new Date();
  return Math.max(0, Math.ceil((end - today) / DAY_MS));
}

This module does the actual work the rest of the tool depends on. Every command you add later, current season, days left, full history, calls one of these three functions instead of writing raw SQL over and over.

Step 10: Build a command-line interface

A CLI turns your database into something you can actually use day to day. Write src/cli.js to read the first argument and dispatch to a handler.

// src/cli.js
import { getAllSeasons, withDurations, getCurrentSeason, daysRemaining } from './timeline.js';

const command = process.argv[2];

switch (command) {
  case 'current': {
    const season = getCurrentSeason();
    if (!season) {
      console.log('No season matches today\'s date. Run npm run refresh or update your seed data.');
      break;
    }
    const remaining = daysRemaining(season);
    console.log(`Chapter ${season.chapter_number}, Season ${season.season_number}: "${season.name}"`);
    console.log(`Started: ${season.start_date}`);
    console.log(`Ends: ${season.end_date ?? 'unannounced'} (${season.date_status})`);
    if (remaining !== null) console.log(`Days remaining: ${remaining}`);
    break;
  }
  case 'list': {
    const seasons = withDurations(getAllSeasons());
    for (const s of seasons) {
      console.log(`Ch.${s.chapter_number} S${s.season_number} — ${s.name} (${s.start_date} to ${s.end_date ?? 'TBD'}, ${s.duration_days ?? '?'} days)`);
    }
    break;
  }
  default:
    console.log('Usage: npm run cli -- current|list');
}

Run npm run cli -- current for a quick status check, or npm run cli -- list to print the full stored history.

Example output

$ npm run cli -- current

Chapter 7, Season 4: "Override"
Started: 2026-08-20
Ends: 2026-10-31 (scheduled)
Days remaining: 38

Your own output will show a different days-remaining figure depending on when you run it, since that number is calculated against the current date, not hardcoded.

Step 11: Export the timeline to CSV

A CSV export makes your data portable into Google Sheets, Excel, or Airtable without anyone needing to touch the database directly. Write src/exportCsv.js with a minimal hand-rolled CSV writer, which is all you need for a dataset this small.

// src/exportCsv.js
import { writeFileSync } from 'node:fs';
import { getAllSeasons, withDurations } from './timeline.js';

const rows = withDurations(getAllSeasons());
const header = ['chapter', 'season', 'name', 'start_date', 'end_date', 'duration_days', 'date_status'];

const csvLines = [header.join(',')];
for (const r of rows) {
  csvLines.push([
    r.chapter_number, r.season_number, `"${r.name}"`, r.start_date,
    r.end_date ?? '', r.duration_days ?? '', r.date_status
  ].join(','));
}

writeFileSync('fortnite-seasons-export.csv', csvLines.join('\n'));
console.log(`Exported ${rows.length} seasons to fortnite-seasons-export.csv`);

Run npm run export and you get a file you can drag straight into a spreadsheet. If your dataset grows large enough to need formulas, pivot tables, or multi-sheet workbooks, the SheetJS library can generate native .xlsx files instead of plain CSV with a few extra lines of code.

Step 12: Automate the refresh

A tracker that only updates when you remember to run a command by hand is not much better than a bookmark. Automate the refresh so the current-season status stays accurate without you thinking about it.

On macOS or Linux, a cron entry running the refresh script twice a day is plenty, since Fortnite seasons do not flip status more than once every couple of months.

# crontab -e
0 8,20 * * * cd /path/to/fortnite-season-tracker && /usr/bin/node src/fetchCurrent.js >> refresh.log 2>&1

If your machine is not always on, a free hosted scheduler like cron-job.org can hit an HTTP endpoint on a schedule instead. That requires wrapping fetchCurrent.js in a tiny web server (a single route is enough) and deploying it somewhere that stays online, which is outside the scope of this build but a natural next step once the core tracker works.

Step 13: Build a lightweight HTML timeline view

You do not need a framework to turn your data into something visual. A single static HTML file that reads your exported CSV and renders a table is enough for personal use, and it is a good foundation if you want to layer in a real front end later. Standard browser APIs handle everything here, and MDN’s JavaScript documentation is the reference to keep open while you extend it.

<!-- timeline.html -->
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Fortnite Season Timeline</title></head>
<body>
  <h1>Fortnite Season Timeline</h1>
  <table id="timeline" border="1" cellpadding="6"></table>

  <script>
    fetch('fortnite-seasons-export.csv')
      .then(r => r.text())
      .then(text => {
        const rows = text.trim().split('\n').map(line => line.split(','));
        const table = document.getElementById('timeline');
        for (const row of rows) {
          const tr = document.createElement('tr');
          for (const cell of row) {
            const td = document.createElement('td');
            td.textContent = cell.replace(/^"|"$/g, '');
            tr.appendChild(td);
          }
          table.appendChild(tr);
        }
      });
  </script>
</body>
</html>

Open the file with a local static server. Loading it directly via file:// can block the fetch call in some browsers due to CORS restrictions on local files. A local server avoids that entirely.

npx serve .
# then open the printed localhost URL and navigate to timeline.html

Fortnite-API.com vs. alternative data sources

Fortnite-API.com is not the only option, and picking the right one depends on how much traffic your tracker needs to handle and how much manual upkeep you are willing to do.

SourceAuth requiredRate limitBest for
Fortnite-API.comOptional (higher limits with a free key)~3 req/sec, 180/minLive cosmetics, current chapter/season confirmation
FortniteAPI.ioAPI key required (header-based)Varies by planShop, news, and challenge data
Fortnite Wiki / FandomNone (manual scraping only)N/A, not an APIHistorical backfill of season names, dates, and themes
Epic official patch notesNoneN/ACanonical confirmation of dates and features once published

A reliable tracker in practice blends the top and bottom rows: an API for automated day-to-day confirmation, official patch notes and archives for one-time historical backfill. Neither source alone gives you the full picture.

Common pitfalls when building a season tracker

  • Trusting a single source for dates. Fan trackers disagree with each other by a day or two more often than you would expect, mostly from timezone conversion. Cross-check at least two sources before treating a date as confirmed.
  • Hardcoding “today” as the season’s real end date. Scheduled end dates shift. Store a date_status field and update it, rather than overwriting history as if it were always accurate.
  • Polling the API too aggressively. Fortnite-API.com’s documented limit is roughly 3 requests per second and 180 per minute. A season tracker needs a fraction of that. Twice-daily automated refreshes are plenty, and hitting the endpoint in a loop risks getting throttled for no benefit.
  • Storing dates as strings without a consistent format. Mixing “Aug 20, 2026” with “2026-08-20” breaks every sort and date-math operation. Normalize everything to ISO 8601 (YYYY-MM-DD) at the point of entry.
  • Treating mini-seasons the same as full seasons. Short-format seasons, some recent examples ran under 30 days, skew average-duration calculations if you do not flag them. Add a season_type field if you plan to do any statistical analysis on duration.
  • Forgetting the unique constraint on upserts. Without UNIQUE(chapter_number, season_number), every refresh script run creates duplicate rows instead of updating the existing one.

Troubleshooting

Here is what tends to go wrong, in the order people usually hit it.

  • “Cannot find module ‘better-sqlite3′”: the install step failed silently, often because of a missing build toolchain. Run npm install better-sqlite3 again and watch for compilation errors in the output. On Windows you may need the “Desktop development with C++” workload from Visual Studio Build Tools.
  • Fetch calls fail with a 401 status: your API key is missing or malformed in the Authorization header. Confirm the environment variable is set in the same terminal session you are running the script from, since env vars set in one shell do not carry to a new terminal window.
  • Fetch calls fail with a 429 status: you are exceeding the rate limit. Add a short delay between calls or reduce how often your cron job runs.
  • getCurrentSeason() returns undefined: your seed data does not have a row whose date range includes today. This usually means a new season launched and you have not added it yet. Check Step 8.
  • CSV opens with everything in one column in Excel: Excel is guessing the wrong delimiter. Use “Data > Text to Columns” and select comma as the delimiter, or rename the file extension to force the correct import dialog.
  • Dates are off by one day after import: this is almost always a timezone conversion bug from parsing an ISO date string with new Date(), which interprets bare date strings as UTC midnight. Compare using UTC-based methods consistently, or append a fixed time like T00:00:00 before parsing.
  • The HTML timeline page shows a blank table: you likely opened the file directly via file://, which blocks the fetch request in most browsers. Serve it through a local server as shown in Step 13.
  • Database file is locked and scripts hang: another process, often a previous script that crashed without closing cleanly, still holds the file open. Close any other terminal sessions using the database and rerun.
  • npm run cli does nothing: remember the double dash. npm swallows arguments after the script name unless you separate them, so npm run cli current silently does nothing while npm run cli -- current works.

Advanced tips once the basics work

Once the core tracker runs reliably, a few upgrades make it genuinely useful instead of just a neat side project.

Add a Discord webhook call inside fetchCurrent.js that fires only when the current season actually changes, not on every refresh. Compare the new season ID against the last one stored, and post a message only on a real transition. That turns a silent script into a channel notification your whole server sees the moment a new season goes live.

Track Battle Pass value over time by populating the battle_passes table alongside seasons. Fortnite’s standard pass has historically run 950 V-Bucks with a bundled option around 2,800 V-Bucks, though pricing and bundle contents change across seasons and modes, so treat any stored price as a snapshot tied to a specific season rather than a constant. Cross-check current pricing against the live in-game store before publishing anything derived from it.

Layer in map change tracking by populating the map_changes table with dated entries whenever a mid-season patch shifts a point of interest. Over several seasons, that table becomes a genuinely useful dataset for spotting patterns, like how often a season opens with a meteor, invasion, or map-wide event versus a quiet rollout.

Why Fortnite season dates disagree across the internet

Before you trust any single source for your seed data, it helps to understand where the disagreement actually comes from, since it explains a lot of the “off by one day” bugs you will otherwise chase for no reason. Epic typically announces a season’s end date inside the Battle Pass screen itself, shown as a countdown rather than a fixed calendar date. That countdown is denominated in the viewer’s local timezone, which means a player in Los Angeles and a player in Tokyo can see end times that translate to different calendar dates in UTC, even though the underlying game-server transition happens at the same instant worldwide.

Community wikis then compile that in-game countdown by hand, usually converting to US Eastern or Pacific time as a convention, which introduces a second layer of possible drift if the person updating the page rounds to the nearest date rather than preserving the exact hour. Add in the fact that Epic has, on more than one occasion, extended a season by a few days close to its scheduled end (typically to accommodate a delayed live event), and you get the pattern seen in this tutorial’s own research: three trackers, three slightly different end dates, all technically defensible depending on when they were last updated.

For your own tracker, the practical fix is the date_status field introduced back in Step 1. Store dates in UTC, note whether a date is confirmed by an official source or estimated from a countdown, and re-run your refresh script close to a season’s scheduled end so any last-minute extension gets picked up before it becomes a stale entry sitting in your database for weeks.

Test the tracker before you rely on it

A season tracker is exactly the kind of small tool people build once, trust blindly, and never revisit until it quietly reports the wrong season for three weeks. A handful of lightweight tests catch the failure modes that matter most: bad date math and duplicate rows. Node’s built-in test runner, available since Node 18, is enough here without pulling in a testing framework.

// test/timeline.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withDurations, daysRemaining } from '../src/timeline.js';

test('withDurations calculates day counts correctly', () => {
  const input = [{ start_date: '2026-08-20', end_date: '2026-10-31' }];
  const [result] = withDurations(input);
  assert.equal(result.duration_days, 72);
});

test('daysRemaining never returns a negative number', () => {
  const pastSeason = { end_date: '2020-01-01' };
  assert.equal(daysRemaining(pastSeason), 0);
});

test('daysRemaining returns null for seasons with no end date', () => {
  const openSeason = { end_date: null };
  assert.equal(daysRemaining(openSeason), null);
});

Run the suite with Node’s built-in runner, no extra install required.

node --test test/

These three tests alone would have caught two of the pitfalls listed earlier: a broken date calculation and a days-remaining function that goes negative once a season ends instead of clamping at zero. Add a fourth test once you have real seed data loaded, asserting that getAllSeasons() returns no duplicate chapter-and-season pairs, which is the fastest way to catch a broken upsert before it pollutes your CSV export.

The complete project, tied together

At this point your project folder looks like this, and every file does exactly one job.

fortnite-season-tracker/
├── data/
│   └── seasons.seed.json      # historical backfill, edit by hand
├── src/
│   ├── db.js                  # schema + connection
│   ├── seed.js                # loads seed JSON into SQLite
│   ├── fetchCurrent.js        # confirms current season via API
│   ├── timeline.js            # shared query + duration logic
│   ├── cli.js                 # current / list commands
│   └── exportCsv.js           # CSV export for spreadsheets
├── timeline.html              # static browser view
├── fortnite-seasons.db        # generated on first run
├── fortnite-seasons-export.csv # generated by npm run export
└── package.json

The full workflow, start to finish, runs like this. Seed the historical data once. Refresh the live status on a schedule. Query it through the CLI whenever you need an answer. Export to CSV when you want to share it. Browse it visually through the static HTML page. None of the individual pieces are complicated. The value is in having them wired together instead of scattered across a dozen browser tabs.

The same discipline applies to other live-service games where an accurate, self-hosted stats layer beats relying on someone else’s dashboard. If you have already built a rank or stats tracker for another title, most of this architecture, the seed-plus-live-refresh pattern in particular, ports over directly.

Frequently asked questions

What chapter and season is Fortnite on right now?
As of September 23, 2026, Fortnite Battle Royale is in Chapter 7, Season 4, subtitled “Override.” It began August 20, 2026, and is scheduled to run through approximately October 31, 2026, based on current tracker reporting, though Epic can adjust that end date.

How long does a typical Fortnite season last?
Duration varies widely. Early Chapter 1 seasons often ran 70-80 days, Chapter 2 Season 1 stretched to roughly 128 days, and some recent mini-seasons have lasted under 30 days. There is no fixed formula, so a tracker that stores actual historical durations is more useful than assuming a standard length.

Is there an official Epic Games API for Fortnite season history?
Not a single public, unrestricted endpoint covering full season history in one normalized response. Developers typically combine a community API like Fortnite-API.com for live confirmation with Epic’s own patch notes and community-maintained wikis for historical backfill.

Do I need an API key to use Fortnite-API.com?
Some endpoints work without one at a lower rate limit. A free key raises your limit to roughly 3 requests per second and 180 per minute, which is more than enough for a personal tracker refreshing a few times a day.

Why use SQLite instead of a hosted database for this project?
The dataset is small, a few hundred rows spanning nine years of seasons, and a single portable file is easier to back up, version, and hand off than running a database server for a project of this size.

Can I track Fortnite OG seasons separately from the main season numbering?
Yes, and you should. OG reruns reuse a past season’s content on a compressed schedule with different dates than the original run. Add a season_type field (standard, OG, mini) so your duration and pattern analysis does not mix the two.

How often should I refresh the live data?
Twice a day is plenty. Fortnite seasons do not change status more than once every couple of months, and Fortnite-API.com’s rate limits are generous enough that frequency was never the constraint. Timing around an actual season transition is what matters.

Can this same architecture track other live-service games?
Yes. The chapters/seasons/events data model and the seed-plus-live-refresh pattern apply to any game with recurring seasonal content. Swap the API source and adjust the schema fields for game-specific data like ranks or battle pass tiers.