Infrastructure Rewrite - skowt.cc

In June 2022, I started building skowt.cc. I was new to web development. I had a static HTML site and a Flask file uploader. skowt became a learning experience at scale.

## Old "Architecture"

The API indexed an R2 bucket. List files from object storage, return the results. That was it.

Every slug-to-display-name mapping lived hardcoded in the frontend. All of them. Not all games appeared at root, so those were hardcoded too.

## Expenses & Caching Nightmare

Cloudflare charges $4.50 per million Class A requests (writes/lists) and $0.36 per million Class B requests (reads). At 300K+ monthly users, list operations became expensive fast.

I implemented aggressive caching to control costs:

  • 1 week cache on game lists
  • 24 hour cache on game and category pages
  • Cloudflare page rules everywhere

Heavy caching created constant problems. Users reported bugs I'd already fixed. New assets didn't appear. My DMs caught fire.

## The Old Upload "Pipeline"

The entire R2 bucket lived in a GitHub repo. Every single asset. On push, a workflow synced the repo contents to R2.

This was slow (20-30 minute deploys, worse with caching), expensive (thousands of write operations per sync), and contributor-hostile (nobody submits PRs to add assets).

Assets rarely updated. Too expensive, too painful.

Could I have added Redis with cache invalidation? Yes. But that adds complexity and failure points without solving the real problem: contributors couldn't upload assets themselves. The architecture was broken, not the caching strategy.

## Mid 2023: The "Rewrite" Begins

Mid-2023, skowt peaked at hundreds of thousands of monthly users. It stopped being a side project.

I evaluated databases and chose Turso (libSQL, a SQLite fork). I use it for everything now.

Then I overcomplicated things. I planned OC generators and features that would turn skowt into a massive platform instead of fixing core issues. Classic scope creep.

Those ideas became originoid. First announced October 2023.

## Burnout Era

Then burnout hit.

I became scared to ship. Lost confidence. Kept delaying releases. When your project serves hundreds of thousands of users, you fixate on pleasing them.

I lost interest in skowt. Stopped playing the games it supported, stopped engaging with the community. But you can't let a project with hundreds of thousands of MAU die.

The site went into minimal maintenance mode. Complete silence until May 2024, when a DDoS forced me to rewrite the entire site to Next.js in hours. I hadn't touched SvelteKit in so long that rewriting was faster than debugging.

After the emergency rewrite, I announced maintenance-only mode. My mental health around the project made this necessary.

## July 2025

While building originoid, I decided to finish skowt. Originoid uses the same stack (Turso, Hono), which made the work straightforward.

The key insight: let Discord contributors upload assets directly instead of relying on me.

## The Migration

I wrote hacky TypeScript scripts and raw SQL.

### How the Migration Actually Worked

Phase 1: Asset Migration

  1. Scan every game folder in R2
  2. Create database entries with UUIDv7 (sortable IDs)
  3. Scan category folders within games
  4. For each asset:
    • Calculate SHA256 hash for deduplication
    • Extract metadata (size, creation date)
    • Generate UUID-based filename
    • Copy to flat structure: /assets/{uuid}.{ext}
    • Insert with relationships

Phase 2: Game-Category Relationships

Built a many-to-many relationship table. Not strictly necessary, but enables search to show which categories exist for which games.

  • Mapped folder structures to game-category pairs
  • Created junction table entries
  • Deduplicated (the old structure had duplicates everywhere)

Phase 3: Auto-Tagging

Tagged all assets as "official" or "fanmade" based on filename patterns and specific rules (Genshin splash arts ending in "-nobg" are fanmade).

Folders and filenames became relational data:

1#games:
2- id (uuidv7)
3- slug
4- name
5
6#categories:
7- id (uuidv7)
8- slug
9- name
10
11#assets:
12- id (uuidv7)
13- name
14- game_id
15- category_id
16- hash (for deduplication)
17- uploaded_by (user id)
18- status (approved/pending, rejected assets are instantly deleted.)
19- ...
20
21#game_to_category:
22- game_id
23- category_id
24
25#asset_to_tag:
26- asset_id
27- tag_id

## The Final Stack

I skipped tRPC. skowt has developers who rely on the API for their own projects. Few OSS repos use this stack, so it serves as a reference.

Deployed on Cloudflare Workers/Pages.

### Backend

  • Hono: API framework
  • Full Zod OpenAPI spec: Type-safe contracts
  • Scalar: API reference
  • Drizzle ORM: Database queries
  • Better Auth: Discord authentication
  • Turso: Database

### Frontend

  • Next.js: Web framework
  • Redux: State sync for multi-select
  • Tailwind: Styling
  • Typed-OpenAPI: API client from backend schema
  • Better Auth: Separate backend/frontend setup

### New Search Implementation

Advanced search with simultaneous filtering:

  • Name search: Partial matching via LIKE queries
  • Tag filtering: AND logic (asset must have ALL tags)
  • Game filtering: Multiple games
  • Category filtering: Multiple categories (character sheets, splash art, etc.)
  • Match validation: Games must have selected categories
  • Sorting: View count, download count, upload date, name
  • Pagination: Offset-based (fetch 21, check for +1 beyond 20 for next page detection)
  • Combination queries: e.g., "fanmade" + "genshin-impact" + "character-sheets"

### Authentication

Discord auth via Better Auth. I usually write auth myself, but I wanted speed here.

Better Auth has a quirk: it uses Discord display names as usernames by default. This breaks with duplicate display names or special characters.

Fix via mapProfileToUser:

1socialProviders: {
2 discord: {
3 overrideUserInfoOnSignIn: true,
4 clientId: env.DISCORD_CLIENT_ID,
5 clientSecret: env.DISCORD_CLIENT_SECRET,
6 mapProfileToUser: async (profile) => {
7 return {
8 name: profile.username, // actual discord username
9 displayName: profile.global_name || profile.username,
10 };
11 },
12 },
13},

## Impact

  • API response time: Fast, zero 500s so far
  • Search: From nothing to full-text with complex filters
  • API docs: Auto-generated, rendered with Scalar
  • Cache duration: Reduced, new assets visible immediately

## Closing Thoughts

Rewriting infrastructure for a high-traffic site while keeping it running is exhausting. The hardest part is starting. After that, it flows.

Nothing has to be perfect initially. Ship it, gather feedback, iterate. Imperfection on first release isn't failure.

### Repos