Into The Void

Elden Ring: Nightreign DB

A searchable database web application

Published on Feb 2 2026 at 2:08pm

Introduction

In this post, we’ll walk through building a web application: a searchable database for the game Elden Ring: Nightreign. This project demonstrates modern web development practices, from Excel data parsing to Docker deployment.

What we’ll build:

Tech Stack:

Live Features:


Table of Contents

  1. Architecture Overview
  2. Project Structure
  3. Data Layer: Excel Parsing
  4. Search Engine Implementation
  5. Component Architecture
  6. State Management & Data Flow
  7. Responsive Design Patterns
  8. Docker Deployment
  9. Performance Optimizations
  10. Lessons Learned

Architecture Overview

High-Level Architecture

graph TB
    subgraph Browser["User Browser"]
        subgraph UI["UI Layer"]
            Header["Header Component"]
            SearchBar["SearchBar Component"]
            CategoryFilter["Category Filter"]
        end

        subgraph App["Main App Component"]
            AppState["Global State Management<br/>- Search Query<br/>- Active Category<br/>- Filtered Results"]
        end

        subgraph Results["Results Display"]
            DataCardGrid["DataCard Grid<br/>- Dynamic rendering<br/>- Expandable cards<br/>- Smart field display"]
        end

        subgraph DataLayer["Data Layer"]
            Hook["useExcelData Hook"]
            Parser["ExcelParser Utility"]
            Search["SearchEngine Utility"]
        end
    end

    subgraph Assets["Static Assets"]
        Excel["nightreign-data.xlsx"]
        Images["Character Images"]
    end

    SearchBar --> AppState
    CategoryFilter --> AppState
    AppState --> DataCardGrid

    App --> Hook
    Hook --> Parser
    Parser --> Search
    Search --> AppState

    Parser -.loads.-> Excel
    Header -.loads.-> Images

    style Browser fill:#1a1a1a,stroke:#666,stroke-width:2px,color:#fff
    style DataLayer fill:#2a2a2a,stroke:#888,stroke-width:2px,color:#fff
    style Assets fill:#3a3a3a,stroke:#aaa,stroke-width:2px,color:#fff

Why This Architecture?

  1. Separation of Concerns: Components handle UI, hooks manage state/data fetching, utilities handle business logic
  2. Reusability: Each component is self-contained and can be reused or tested independently
  3. Performance: Client-side Excel parsing means no backend needed, reducing infrastructure costs
  4. Scalability: Easy to add new data sources or features without refactoring core logic

Project Structure

nightreign-dashboard/
├── public/                          # Static assets served directly
│   ├── duchess.png                  # Logo 
│   └── nightreign-data.xlsx         # Game data (Excel format)
│
├── src/
│   ├── components/                  # React components
│   │   ├── DataCard.jsx            # Individual result card with expand/collapse
│   │   ├── SearchBar.jsx           # Search input with clear button
│   │   ├── CategoryFilter.jsx      # Category filtering buttons
│   │   └── Header.jsx              # App header with branding
│   │
│   ├── hooks/                       # Custom React hooks
│   │   └── useExcelData.js         # Hook for loading & caching Excel data
│   │
│   ├── utils/                       # Business logic utilities
│   │   ├── excelParser.js          # Excel file parsing with SheetJS
│   │   └── searchEngine.js         # Search & categorization logic
│   │
│   ├── styles/
│   │   └── index.css               # Tailwind imports + custom styles
│   │
│   ├── App.jsx                      # Main application component
│   └── main.jsx                     # Application entry point
│
├── Dockerfile                       # Multi-stage Docker build
├── docker-compose.yml               # Container orchestration
├── nginx.conf                       # Web server configuration
├── vite.config.js                  # Build tool configuration
├── tailwind.config.js              # Styling configuration
└── package.json                    # Dependencies & scripts

Key Design Decisions

Why Vite over Create React App?

Why Tailwind CSS?

Why SheetJS for data?


Data Layer: Excel Parsing

The Challenge

I’ve been a huge fan of this difficult and deep game. There’s a lot of scattered information about it all over the internet but I wanted to organise and have all this information in one place.

I first came across this excel sheet on Reddit created by data miners of the game -

Nightreign Relic Stat Spreadsheet

This sheet contained a lot of information about the game, but it is frustrating to quickly find relevant information in it, especially on a mobile screen.

We have an Excel workbook with 18 sheets containing different types of game data:

Each sheet has different columns, and we need to parse them all into a searchable format.

Solution: Flexible Parser

src/utils/excelParser.js

import * as XLSX from 'xlsx';

// Sheets we want to ignore (credits, outdated data, etc.)
const IGNORED_SHEETS = [
  'Credits and Useful Links',
  'Chalices',
  'Character Stats Table (Outdated',
  'Guaranteed Relics'
];

/**
 * Loads Excel file from URL and parses all sheets
 * Returns: { sheetName: [row1, row2, ...], ... }
 */
export async function loadExcelFile(url) {
  try {
    // Fetch the Excel file
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Failed to fetch: ${response.statusText}`);
    }

    // Convert to ArrayBuffer (binary data)
    const arrayBuffer = await response.arrayBuffer();

    // Parse with SheetJS
    const workbook = XLSX.read(arrayBuffer, { type: 'array' });

    return parseWorkbook(workbook);
  } catch (error) {
    console.error('Error loading Excel file:', error);
    throw error;
  }
}

/**
 * Parses workbook into structured data
 */
export function parseWorkbook(workbook) {
  const data = {};

  workbook.SheetNames.forEach(sheetName => {
    // Skip ignored sheets
    if (IGNORED_SHEETS.includes(sheetName)) {
      return;
    }

    const worksheet = workbook.Sheets[sheetName];

    // Convert sheet to JSON array of objects
    // Each row becomes an object with column names as keys
    const jsonData = XLSX.utils.sheet_to_json(worksheet, {
      raw: false,  // Convert dates/numbers to strings
      defval: ''   // Default value for empty cells
    });

    // Only include sheets with data
    if (jsonData.length > 0) {
      data[sheetName] = jsonData;
    }
  });

  return data;
}

Key Concepts

1. Fetch API for File Loading

const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();

2. SheetJS sheet_to_json()

XLSX.utils.sheet_to_json(worksheet, {
  raw: false,  // Why? Ensures consistent string values
  defval: ''   // Why? Prevents null/undefined in our data
});

3. Dynamic Sheet Handling

Custom Hook for Data Management

src/hooks/useExcelData.js

import { useState, useEffect } from 'react';
import { loadExcelFile } from '../utils/excelParser';

export function useExcelData(fileUrl = '/nightreign-data.xlsx') {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const loadData = async () => {
    setLoading(true);
    setError(null);

    try {
      const parsedData = await loadExcelFile(fileUrl);
      setData(parsedData);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  // Load data on component mount
  useEffect(() => {
    loadData();
  }, [fileUrl]);

  return {
    data,      // Parsed Excel data
    loading,   // Loading state for UI
    error,     // Error message if load fails
    reload: loadData  // Function to reload data
  };
}

Why a Custom Hook?

Usage in Components:

function App() {
  const { data, loading, error } = useExcelData();

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!data) return <div>No data</div>;

  // Use data...
}

Search Engine Implementation

The Requirements

  1. Search across ALL sheets and ALL columns
  2. Case-insensitive partial matching
  3. Category-based filtering
  4. Fast enough for real-time search (as user types)
  5. Return results with metadata (sheet name, category)

Solution: Efficient Search Algorithm

src/utils/searchEngine.js

/**
 * Categorizes sheet names into display categories
 */
export function categorizeSheet(sheetName) {
  const lower = sheetName.toLowerCase();

  // Priority order matters! More specific checks first
  if (lower === 'level up cost') return 'Levels';
  if (lower.includes('nightlord')) return 'Nightlord Stats';
  if (lower.includes('everdark') || lower.includes('sovereign'))
    return 'Everdark Sovereign Stats';

  if (lower.includes('talisman')) return 'Talismans';
  if (lower.includes('weapon')) return 'Weapons';
  if (lower.includes('dormant')) return 'Dormant Powers';
  if (lower.includes('relic')) return 'Relics';
  if (lower.includes('consumable')) return 'Consumables';
  if (lower.includes('character') && lower.includes('stat')) return 'Stats';

  return 'Other';  // Catch-all for unknown sheets
}

/**
 * Searches all sheets for matching rows
 */
export function searchAllSheets(data, query = '', categoryFilter = 'all') {
  if (!data || typeof data !== 'object') return [];

  const normalizedQuery = query.toLowerCase().trim();
  const results = [];

  // Iterate through each sheet
  Object.entries(data).forEach(([sheetName, rows]) => {
    if (!Array.isArray(rows)) return;

    const category = categorizeSheet(sheetName);

    // Skip 'Other' category completely
    if (category === 'Other') return;

    // Apply category filter
    if (categoryFilter !== 'all' && category !== categoryFilter) {
      return;
    }

    // Search through rows
    rows.forEach(row => {
      // If no query, include all items (with category filter applied)
      if (normalizedQuery === '') {
        results.push({
          ...row,
          _sheet: sheetName,
          _category: category
        });
        return;
      }

      // Check if any cell value contains the search term
      const matches = Object.entries(row).some(([key, value]) => {
        // Skip empty values
        if (value === null || value === undefined || value === '')
          return false;

        // Convert to string and search (case-insensitive)
        return String(value).toLowerCase().includes(normalizedQuery);
      });

      if (matches) {
        results.push({
          ...row,
          _sheet: sheetName,
          _category: category
        });
      }
    });
  });

  return results;
}

Algorithm Analysis

Time Complexity: O(n × m × k)

Why This Is Fast Enough:

  1. JavaScript engines optimize string operations
  2. Early returns reduce unnecessary iterations
  3. Client-side means no network latency
  4. Typical dataset: ~500 rows across 18 sheets = fast enough

Performance Optimization Opportunities:

Category Extraction

export function getCategories(data) {
  if (!data || typeof data !== 'object') return [];

  const categories = new Set();  // Set = no duplicates

  Object.keys(data).forEach(sheetName => {
    const category = categorizeSheet(sheetName);
    if (category !== 'Other') {
      categories.add(category);
    }
  });

  return Array.from(categories).sort();  // Alphabetical order
}

Why Set?


Component Architecture

1. DataCard Component

The heart of the application - displays individual items with smart field handling.

Key Features:

src/components/DataCard.jsx

import React, { useState } from 'react';
import { ChevronDown, ChevronUp, /* ...icons... */ } from 'lucide-react';

// Color schemes for each category
const CATEGORY_CONFIG = {
  'Talismans': {
    icon: Shield,
    color: 'text-blue-400 border-blue-900/30 bg-blue-900/10',
    accent: 'bg-blue-600'
  },
  'Weapons': {
    icon: Sword,
    color: 'text-red-400 border-red-900/30 bg-red-900/10',
    accent: 'bg-red-600'
  },
  // ... more categories
};

// Sheet-specific title columns
const SHEET_TITLE_COLUMN = {
  'Weapon Effects': 'Effect Description In-Game',
  'Relic Effects': 'Relic Description',
  'Dormant Powers': 'Dormant Power'
};

export function DataCard({ item, searchQuery }) {
  const [isExpanded, setIsExpanded] = useState(false);

  const category = item._category || 'Other';
  const sheetName = item._sheet || 'Unknown';
  const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG['Other'];

  // Determine title column for this sheet
  const titleColumn = SHEET_TITLE_COLUMN[sheetName];
  const title = titleColumn && item[titleColumn]
    ? item[titleColumn]
    : item.Name || 'Unknown Item';

  // Filter display keys (exclude metadata and title)
  const displayKeys = Object.keys(item).filter(key =>
    !key.startsWith('_') &&
    key !== titleColumn &&
    item[key] !== null &&
    item[key] !== undefined &&
    item[key] !== ''
  );

  const hasMoreFields = displayKeys.length > 8;
  const fieldsToShow = isExpanded ? displayKeys : displayKeys.slice(0, 8);

  return (
    <div className="card-container">
      {/* Colored accent line */}
      <div className={`accent-line ${config.accent}`} />

      <div className="card-content">
        {/* Header with title and icon */}
        <div className="card-header">
          <h3>{title}</h3>
          <span className="category-badge">{category}</span>
        </div>

        {/* Data fields */}
        <div className="fields-container">
          {fieldsToShow.map(key => (
            <div key={key} className="field-row">
              <span className="field-label">{key}</span>
              <span className="field-value">{item[key]}</span>
            </div>
          ))}

          {/* Expand/collapse button */}
          {hasMoreFields && (
            <button onClick={() => setIsExpanded(!isExpanded)}>
              {isExpanded ? (
                <>Show less <ChevronUp /></>
              ) : (
                <>+{displayKeys.length - 8} more fields <ChevronDown /></>
              )}
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

Design Patterns Used:

  1. Controlled Components: isExpanded state controlled by React
  2. Conditional Rendering: Show different UI based on state
  3. Dynamic Styling: Tailwind classes computed from data
  4. Component Composition: Small, focused component with single responsibility

2. SearchBar Component

Simple but effective search input with clear functionality.

src/components/SearchBar.jsx

export function SearchBar({ value, onChange, onClear }) {
  return (
    <div className="search-container">
      <Search className="search-icon" />
      <input
        type="text"
        placeholder="Search for effects, items, stats..."
        value={value}
        onChange={(e) => onChange(e.target.value)}
      />
      {value && (
        <button onClick={onClear}>
          <X className="clear-icon" />
        </button>
      )}
    </div>
  );
}

Key Points:

3. CategoryFilter Component

Dynamic category buttons generated from data.

src/components/CategoryFilter.jsx

const CATEGORY_ICONS = {
  'All': Sparkles,
  'Talismans': Shield,
  'Weapons': Sword,
  'Levels': TrendingUp,
  // ... more mappings
};

export function CategoryFilter({ categories, activeCategory, onCategoryChange }) {
  const allCategories = ['All', ...categories];

  return (
    <div className="category-filters">
      {allCategories.map(category => {
        const Icon = CATEGORY_ICONS[category] || Sparkles;
        const isActive = activeCategory === category;

        return (
          <button
            key={category}
            onClick={() => onCategoryChange(category)}
            className={isActive ? 'active' : 'inactive'}
          >
            <Icon />
            {category}
          </button>
        );
      })}
    </div>
  );
}

Responsive Design:


State Management & Data Flow

Application State Architecture

src/App.jsx

import { useState, useMemo } from 'react';
import { useExcelData } from './hooks/useExcelData';
import { searchAllSheets, getCategories } from './utils/searchEngine';

function App() {
  // Load Excel data
  const { data, loading, error } = useExcelData();

  // UI state
  const [searchQuery, setSearchQuery] = useState('');
  const [activeCategory, setActiveCategory] = useState('All');

  // Derive categories from data
  const categories = useMemo(() => {
    return data ? getCategories(data) : [];
  }, [data]);

  // Perform search (memoized for performance)
  const searchResults = useMemo(() => {
    if (!data) return [];

    const categoryFilter = activeCategory === 'All'
      ? 'all'
      : activeCategory;

    return searchAllSheets(data, searchQuery, categoryFilter);
  }, [data, searchQuery, activeCategory]);

  // Event handlers
  const handleSearch = (query) => {
    setSearchQuery(query);
  };

  const handleCategoryChange = (category) => {
    setActiveCategory(category);
  };

  const handleClearSearch = () => {
    setSearchQuery('');
  };

  // Render
  return (
    <div className="app">
      <Header loading={loading} />

      <main>
        <SearchBar
          value={searchQuery}
          onChange={handleSearch}
          onClear={handleClearSearch}
        />

        <CategoryFilter
          categories={categories}
          activeCategory={activeCategory}
          onCategoryChange={handleCategoryChange}
        />

        <div className="results-grid">
          {searchResults.map((item, index) => (
            <DataCard
              key={`${item._sheet}-${index}`}
              item={item}
              searchQuery={searchQuery}
            />
          ))}
        </div>

        {searchResults.length === 0 && (
          <div className="no-results">
            No results found for "{searchQuery}"
          </div>
        )}
      </main>
    </div>
  );
}

Data Flow Diagram

flowchart TD
    A[User Input<br/>Search/Filter] --> B[Update State<br/>useState]
    B --> C[Trigger Recalculation<br/>useMemo]
    C --> D[searchAllSheets<br/>runs]
    D --> E[Return Filtered<br/>Results]
    E --> F[React Re-renders<br/>Components]
    F --> G[Display Updated<br/>Results]

    style A fill:#4a5568,stroke:#718096,stroke-width:2px,color:#fff
    style B fill:#2d3748,stroke:#4a5568,stroke-width:2px,color:#fff
    style C fill:#2d3748,stroke:#4a5568,stroke-width:2px,color:#fff
    style D fill:#d97706,stroke:#f59e0b,stroke-width:2px,color:#fff
    style E fill:#2d3748,stroke:#4a5568,stroke-width:2px,color:#fff
    style F fill:#059669,stroke:#10b981,stroke-width:2px,color:#fff
    style G fill:#059669,stroke:#10b981,stroke-width:2px,color:#fff

Performance: useMemo Explained

Without useMemo:

// BAD: Runs on EVERY render, even unrelated changes
const searchResults = searchAllSheets(data, searchQuery, activeCategory);

With useMemo:

// GOOD: Only runs when dependencies change
const searchResults = useMemo(() => {
  return searchAllSheets(data, searchQuery, activeCategory);
}, [data, searchQuery, activeCategory]);

Why It Matters:


Responsive Design Patterns

Mobile-First Approach

This was an important requirement for me since quickly finding information in the excel workbook on a mobile screen was a major pain point for me, which led to the creation of this webapp.

Tailwind’s responsive utilities work mobile-first:

// Mobile: text-base (16px) → Desktop: md:text-lg (18px)
className="text-base md:text-lg"

Key Breakpoints

// Tailwind default breakpoints
sm: 640px   // Small tablets
md: 768px   // Tablets
lg: 1024px  // Laptops
xl: 1280px  // Desktops

Common Patterns Used

1. Responsive Spacing:

// Mobile: 4px, Desktop: 8px
className="mt-4 md:mt-8"

2. Responsive Sizing:

// Header logo: 112px mobile, 160px desktop
className="h-28 w-28 md:h-40 md:w-40"

3. Flex Wrapping:

// Single column mobile, wraps to multi-column desktop
className="flex flex-wrap gap-2"

4. Text Scaling:

// Button text: 12px mobile, 14px desktop
className="text-xs md:text-sm"

Mobile-Specific Fixes

Problem: iOS Auto-Zoom on Input Focus

/* BAD: iOS zooms on inputs < 16px */
font-size: 14px;

/* GOOD: Prevents auto-zoom */
font-size: 16px;

Solution in Code:

className="text-base md:text-lg"  // 16px mobile, 18px desktop

Docker Deployment

Multi-Stage Build

Dockerfile

# Stage 1: Build
FROM node:20-alpine AS builder

WORKDIR /app

# Copy dependency files
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy source code
COPY . .

# Build production bundle
RUN npm run build

# Stage 2: Production
FROM nginx:alpine

# Copy built files from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html

# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Fix file permissions for nginx user
RUN chmod -R 755 /usr/share/nginx/html && \
    chown -R nginx:nginx /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Why Multi-Stage Build?

  1. Smaller Image: Final image only includes built files, not source code or build tools
  2. Security: No development dependencies in production
  3. Faster Deploys: Smaller images = faster transfers
  4. Clean Separation: Build environment isolated from runtime

Size Comparison:

nginx Configuration

nginx.conf

server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # Include default MIME types (CRITICAL!)
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # Enable CORS (for local dev/testing)
    add_header Access-Control-Allow-Origin * always;

    # SPA fallback: serve index.html for all routes
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Excel files: correct content type
    location ~* \.(xlsx|xls)$ {
        add_header Content-Type application/vnd.openxmlformats-officedocument.spreadsheetml.sheet always;
        add_header Cache-Control "public, max-age=3600" always;
    }

    # Static assets: aggressive caching
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Key Configurations Explained:

  1. MIME Types: Ensures browsers know how to handle files

  2. SPA Fallback: All routes serve index.html (React Router)

  3. Excel MIME Type: Critical for fetch() to work correctly

  4. Caching Strategy:

    • Excel: 1 hour (data may update)
    • Static assets: 1 year (hashed filenames)

Docker Compose

docker-compose.yml

version: '3.8'

services:
  nightreign-dashboard:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: nightreign-dashboard
    ports:
      - "3383:80"
    restart: unless-stopped
    environment:
      - NODE_ENV=production

Usage:

# Build and start
docker-compose up --build

# Run in background
docker-compose up -d

# Stop
docker-compose down

# View logs
docker-compose logs -f

Performance Optimizations

1. Vite Build Optimizations

vite.config.js

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'dist',
    sourcemap: false,  // Smaller production bundle
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['react', 'react-dom'],  // Separate vendor chunk
          'xlsx': ['xlsx']  // Separate Excel library
        }
      }
    }
  }
});

Benefits:

2. Code Splitting

Vite automatically splits:

3. Image Optimization

// Duchess character image
<img
  src="/duchess.png"
  alt="The Duchess"
  className="h-28 w-28 md:h-40 md:w-40 object-cover"
  loading="lazy"  // Lazy load if off-screen
/>

4. React Performance

useMemo for Expensive Calculations:

const searchResults = useMemo(() => {
  return searchAllSheets(data, searchQuery, activeCategory);
}, [data, searchQuery, activeCategory]);

Proper Key Props:

{results.map((item, index) => (
  <DataCard
    key={`${item._sheet}-${index}`}  // Unique key
    item={item}
  />
))}

5. Bundle Size Analysis

# Build with analysis
npm run build -- --mode=analyze

# Or manually check
ls -lh dist/assets/

Current Bundle Sizes:

Total: ~377KB *Reasonable for a data-heavy application


Lessons Learned

1. Excel as a Data Source

Pros:

Cons:

When to Use:

When to Avoid:

2. Client-Side Search Limitations

Works Well For:

Breaks Down At:

Solution for Scale:

3. Responsive Design Insights

Mobile-First Is Critical:

Common Mobile Issues:

Testing Strategy:

4. Docker Deployment Gotchas

MIME Types Are Critical:

# Without this, browsers download HTML as .dms files!
include /etc/nginx/mime.types;

File Permissions Matter:

# nginx user needs read access
RUN chmod -R 755 /usr/share/nginx/html

CORS for Local Dev:

# Allow localhost testing
add_header Access-Control-Allow-Origin * always;

5. Component Architecture Lessons

Keep Components Small:

Lift State Up:

Use Custom Hooks:


Conclusion

We’ve built a complete, production-ready web application that demonstrates:

Key Takeaways

  1. Architecture Matters: Separation of concerns makes code maintainable
  2. Performance First: useMemo, code splitting, caching strategies
  3. Responsive is Non-Negotiable: Mobile-first design is table stakes
  4. Deploy Early: Docker + nginx = production-ready from day one
  5. User Experience: Small details (expand cards, clear search) matter

Next Steps & Improvements

Short Term:

Medium Term:

Long Term:

Ideas:

Resources

Documentation:

Tools Used:

Source Code:


About This Project

Elden Ring: Nightreign DB is a database for Elden Ring: Nightreign. Built with modern web technologies, it provides fast, searchable access to game mechanics, items, and stats.

Tech Stack Summary:

Contributing:


Tags: elden ring , nightreign , react , ai