{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreic4tvn5els6gyjbvzsnn4k3px74sfu7o2jfrx42y6iq7bkrxnmhja",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mokd6pskxff2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreieedczbm6tqqjpfauocxlcybfl6yxdpahmrdi67khkyljxfqfzzxq"
    },
    "mimeType": "image/webp",
    "size": 431174
  },
  "path": "/aws/building-a-world-cup-bracket-picker-with-aws-blocks-1k8",
  "publishedAt": "2026-06-18T07:28:45.000Z",
  "site": "https://dev.to",
  "tags": [
    "aws",
    "fullstack",
    "blocks",
    "AWS Blocks",
    "GitHub",
    "Kiro",
    "openfootball's World Cup JSON feed",
    "AWS Blocks product page",
    "Getting started guide",
    "AWS Blocks on GitHub",
    "@aws-blocks"
  ],
  "textContent": "AWS just launched AWS Blocks, an open-source TypeScript framework that gives you backend capabilities on AWS without learning infrastructure tools. Everything runs locally without an AWS account. When you're ready, deploy the same code to AWS with zero changes.\n\nIn this post, I'll build a full-stack World Cup bracket picker with it. The app lets users:\n\n  * Pick 1st, 2nd, and 3rd place in each of the 12 groups\n  * Predict knockout round winners all the way to the final\n  * Chat with an AI agent that knows every team's roster and FIFA ranking\n  * See other users' picks appear in real time\n  * Automatically sync real match results on an hourly schedule\n  * Compete on a leaderboard once real results come in\n\n\n\nThe full source code is on GitHub. The `mock` branch has the frontend-only starting point with prompts if you want to build along.\n\n##  Prerequisites\n\n  * Node.js 22 or higher\n  * An IDE (Kiro is preferred)\n  * Ollama (optional, for running the AI agent locally)\n\n\n\n##  Getting ready\n\nClone the repository and checkout the `mock` branch. This gives you a React 19 + Vite + Tailwind frontend with all the UI components already built, but no backend.\n\n\n\n    git clone https://github.com/salihgueler/worldcup-bracket-picker.git\n    cd worldcup-bracket-picker\n    git checkout mock\n    npm install\n    npm run dev\n\n\nOpen `http://localhost:3000` to see the UI shell. Nothing works yet because there's no backend.\n\nNext, add AWS Blocks to the project:\n\n\n\n    npm create @aws-blocks/blocks-app@latest .\n\n\nThis scaffolds an `aws-blocks/` folder with a dev server, CDK deployment config, and a sample todo app. We'll replace the sample code with our own. Run `npm run dev` again and you'll see both the Vite frontend on port 3000 and the Blocks backend on port 3001.\n\n##  Authentication\n\nAWS Blocks offers different authentication types: basic username/password, Cognito User Pools, and OIDC/OAuth2 with external providers like Google or GitHub. For this app, we'll use basic auth. It stores credentials in a database and issues JWT tokens for session management.\n\n\n\n    import { Scope, AuthBasic } from \"@aws-blocks/blocks\";\n\n    const scope = new Scope(\"wc\");\n\n    const auth = new AuthBasic(scope, \"auth\", {\n      passwordPolicy: { minLength: 8, requireDigits: true },\n    });\n\n    export const authApi = auth.createApi();\n\n\n`Scope` defines the resource boundary for the app. All blocks attach to it. `AuthBasic` creates the auth system with a password policy. `auth.createApi()` exports a state-machine API that the frontend Authenticator widget hooks into.\n\nYou can configure session duration, cross-domain cookies for sandbox mode, email code delivery, and more. For now, the defaults work fine.\n\nOn the frontend, open `AuthGate.tsx` and wire up the Authenticator widget:\n\n\n\n    import { useEffect, useRef, type ReactNode } from \"react\";\n    import { authApi } from \"aws-blocks\";\n    import { Authenticator } from \"@aws-blocks/blocks/ui\";\n    import { useAuth } from \"../hooks/useAuth\";\n\n    export function AuthGate({ children }: { children: ReactNode }) {\n      const { user, loading } = useAuth();\n      const mountRef = useRef<HTMLDivElement>(null);\n\n      useEffect(() => {\n        if (loading || user || !mountRef.current) return;\n        const host = mountRef.current;\n        host.innerHTML = \"\";\n        host.appendChild(Authenticator(authApi));\n        return () => {\n          host.innerHTML = \"\";\n        };\n      }, [loading, user]);\n\n      if (loading) return <div className=\"loading\">Loading...</div>;\n      if (!user) return <div ref={mountRef} />;\n      return <>{children}</>;\n    }\n\n\nThe `Authenticator` is a framework-agnostic DOM element. It renders sign-up/sign-in forms and is tied directly to `authApi`. When auth state changes, it updates automatically. The `useAuth` hook listens for those changes:\n\n\n\n    import { useState, useEffect, useCallback } from \"react\";\n    import { authApi } from \"aws-blocks\";\n    import { onAuthChange, broadcastAuthChange } from \"@aws-blocks/blocks/ui\";\n\n    export interface AuthUser {\n      userId: string;\n      username: string;\n    }\n\n    export function useAuth() {\n      const [user, setUser] = useState<AuthUser | null>(null);\n      const [loading, setLoading] = useState(true);\n\n      useEffect(() => {\n        const unsubscribe = onAuthChange(authApi, (u) => {\n          setUser(u ? { userId: u.userId, username: u.username } : null);\n          setLoading(false);\n        });\n        return unsubscribe;\n      }, []);\n\n      const signOut = useCallback(async () => {\n        const next = await authApi.setAuthState({ action: \"signOut\" });\n        broadcastAuthChange(next.user ?? null);\n      }, []);\n\n      return { user, loading, signOut };\n    }\n\n\n`onAuthChange` subscribes to auth state changes across the same window and across tabs. It fires immediately with the current user, then on every sign-in or sign-out.\n\n##  Data\n\nBlocks gives you three storage options: NoSQL tables (`DistributedTable`), Postgres (`Database`), and key-value (`KVStore`). We'll use `DistributedTable` for structured data with indexes and `KVStore` for simple flags.\n\nThe scaffolder generates a sample todos table. Here's what a `DistributedTable` looks like:\n\n\n\n    const todoSchema = z.object({\n      userId: z.string(),\n      todoId: z.string(),\n      title: z.string(),\n      completed: z.boolean(),\n      priority: z.number(),\n      version: z.number(),\n      createdAt: z.number(),\n    });\n\n    const todos = new DistributedTable(scope, \"todos\", {\n      schema: todoSchema,\n      key: { partitionKey: \"userId\", sortKey: \"todoId\" },\n      indexes: {\n        byPriority: { partitionKey: \"userId\", sortKey: \"priority\" },\n        byTitle: { partitionKey: \"userId\", sortKey: \"title\" },\n      },\n    });\n\n\nOne Zod schema gives you runtime validation, TypeScript types, and the database shape in a single definition. The `partitionKey` determines how items are distributed across storage. The `sortKey` orders items within a partition. Indexes let you query by different sort orders without scanning the entire table.\n\nRemove the todos code and add the match table for our World Cup data:\n\n\n\n    const matchSchema = z.object({\n      matchId: z.string(),\n      matchType: z.string(),\n      stage: z.string(),\n      team1Id: z.string(),\n      team2Id: z.string(),\n      scheduledDate: z.string(),\n      result: z.string().optional(),\n      score: z.string().optional(),\n    });\n\n    const matches = new DistributedTable(scope, \"matches\", {\n      schema: matchSchema,\n      key: { partitionKey: \"matchType\", sortKey: \"matchId\" },\n      indexes: {\n        byStage: { partitionKey: \"stage\", sortKey: \"matchId\" },\n      },\n    });\n\n\nFor simple per-user state like \"has this user locked their bracket?\", `KVStore` is easier than a full table:\n\n\n\n    const lockStore = new KVStore<boolean>(scope, \"bracket-lock\");\n\n\nCRUD operations are straightforward:\n\n\n\n    // Upsert (insert or update)\n    await matches.put({ ...match, result, score });\n\n    // Batch write\n    await matches.putBatch(items);\n\n    // Delete\n    await matches.delete({ matchType: \"MATCH\", matchId });\n\n    // Query by index\n    const groupMatches = await Array.fromAsync(\n      matches.query({\n        index: \"byStage\",\n        where: { stage: { equals: \"group\" } },\n      })\n    );\n\n\nThe frontend calls these through `ApiNamespace` methods. Types flow end-to-end from the Zod schema to the frontend function call with no code generation step.\n\n##  Realtime\n\nBlocks supports WebSocket pub/sub through the `Realtime` block. In our app, users see other people's bracket picks appear live as they're made.\n\nFirst, create the picks table and a Realtime block:\n\n\n\n    const picks = new DistributedTable(scope, \"picks\", {\n      schema: pickSchema,\n      key: { partitionKey: \"oddsType\", sortKey: \"oddsId\" },\n      indexes: {\n        byUser: { partitionKey: \"userId\", sortKey: \"matchId\" },\n        byMatch: { partitionKey: \"matchId\", sortKey: \"userId\" },\n      },\n    });\n\n    const PICKS_CHANNEL = \"all\";\n    const rt = new Realtime(scope, \"rt\", {\n      namespaces: {\n        picks: Realtime.namespace(\n          z.object({\n            userId: z.string(),\n            username: z.string(),\n            matchId: z.string(),\n            predictedWinner: z.string(),\n          }),\n        ),\n      },\n    });\n\n\nWhen a user makes a pick, publish it to the channel:\n\n\n\n    await rt.publish(\"picks\", PICKS_CHANNEL, {\n      userId: user.userId,\n      username: user.username,\n      matchId,\n      predictedWinner,\n    });\n\n\nOn the frontend, subscribe to the channel and render events as they arrive:\n\n\n\n    const sub = channel.subscribe((msg: PickEvent) => {\n      setEvents((prev) => [msg, ...prev].slice(0, MAX_EVENTS));\n    });\n\n\nWhat this gives you:\n\n  * One Zod schema defines the database shape, TypeScript types, and runtime validation. Defined once.\n  * `makePick` does auth, a database write, and a realtime broadcast in three lines. No API Gateway config, no DynamoDB setup, no WebSocket server.\n  * The same code runs locally with automatic mocks and deploys to AWS with zero config.\n  * The realtime payload type flows straight from the schema into your `subscribe` handler with full type safety.\n\n\n\n##  Agents\n\nMy favorite feature of Blocks is the Agent block. You define an AI agent with tools that have direct access to your data layer. Locally it runs with Ollama (or a canned mock if Ollama isn't available). On AWS it runs on Amazon Bedrock.\n\n\n\n    const predictor = new Agent(scope, \"predictor\", {\n      model: {\n        deployed: BedrockModels.BALANCED,\n        local: OllamaModels.SMALL,\n      },\n      systemPrompt: [\n        \"You are the official AI predictor for FIFA World Cup 2026.\",\n        \"You help fans understand the teams and forecast match outcomes.\",\n        \"Always ground your answers in real data by calling your tools:\",\n        \"- lookupTeam to fetch a team's group, FIFA ranking, and confederation\",\n        \"- getTeamSquad to inspect a team's player roster\",\n        \"- getMatchConsensus to see how the community has picked a match\",\n        \"- getUserBracket to review the current user's predictions\",\n        \"- getMatchResult to fetch the actual outcome of a played match\",\n      ].join(\"\\n\"),\n      toolContextSchema: z.object({ userId: z.string() }),\n      tools: (tool) => ({\n        lookupTeam: tool({\n          description: \"Look up a team's details by id or name\",\n          parameters: z.object({\n            teamId: z.string().describe(\"Team id (e.g. 'BRA') or full name\"),\n          }),\n          handler: async ({ input }) => {\n            const direct = await teams.get({ type: \"TEAM\", teamId: input.teamId });\n            if (direct) return direct;\n            // Fallback: case-insensitive name search\n            const all = await Array.fromAsync(\n              teams.query({ where: { type: { equals: \"TEAM\" } } })\n            );\n            const needle = input.teamId.trim().toLowerCase();\n            return all.find(\n              (t) => t.name.toLowerCase().includes(needle) ||\n                     t.teamId.toLowerCase() === needle\n            ) ?? { error: `No team found matching \"${input.teamId}\"` };\n          },\n        }),\n        // getTeamSquad, getMatchConsensus, getUserBracket, getMatchResult...\n      }),\n    });\n\n\nThe `tools` callback pattern gives each tool typed `input` derived from its Zod `parameters` schema. The `toolContextSchema` passes the authenticated user's ID into tools so they can scope queries to the caller, without the model seeing it.\n\nTo expose the agent via your API:\n\n\n\n    export const api = new ApiNamespace(scope, \"api\", (context) => ({\n      async chatWithPredictor(message: string) {\n        const user = await auth.requireAuth(context);\n        let conversationId = await predictorConversations.get(user.username);\n        if (!conversationId) {\n          conversationId = await predictor.createConversationId(user.username);\n          await predictorConversations.put(user.username, conversationId);\n        }\n        const result = await predictor.stream(message, {\n          conversationId,\n          userId: user.username,\n          context: { userId: user.username },\n        });\n        return { reply: (await result.complete()).text ?? \"\" };\n      },\n    }));\n\n\nFrom the frontend, one function call:\n\n\n\n    const { reply } = await api.chatWithPredictor(message);\n\n\nTo run the agent locally with a real LLM, install Ollama and pull a model:\n\n\n\n    ollama serve\n    ollama pull llama3.1:8b\n\n\nIf Ollama isn't running, Blocks falls back to a canned provider that returns keyword-based mock responses. Zero config needed either way.\n\n##  Scheduled tasks\n\nAWS Blocks lets you write cloud functions that trigger on a schedule. For our app, an hourly job checks for new match results from a public API, updates the database, and refreshes the leaderboard:\n\n\n\n    new CronJob(scope, \"results-sync\", {\n      schedule: \"rate(1 hour)\",\n      description: \"Check for finished matches and refresh the leaderboard.\",\n      handler: async (event) => {\n        console.log(`[results-sync] triggered at ${event.scheduledTime}`);\n        const summary = await syncMatchResultsFromFeed();\n        const standings = await refreshLeaderboard();\n        console.log(\n          `[results-sync] done — checked ${summary.checked}, ` +\n          `updated ${summary.updated}; leaderboard has ${standings.length} entries`\n        );\n      },\n    });\n\n\nThe handler fetches results from openfootball's World Cup JSON feed, matches them against our fixtures, writes scores to the database, and recomputes standings. Locally, the job runs synchronously in-process when triggered. On AWS, it becomes an EventBridge Scheduler + Lambda.\n\n##  Running the app\n\n\n    npm run dev\n\n\nOpen `http://localhost:3000`. Sign up with a username and password. On first login, `ensureSeeded()` populates the database with all 48 teams, their 26-player rosters, and 88 group-stage matches. Start picking your bracket.\n\nMock data persists in `.bb-data/` across dev server restarts. To reset everything: `rm -rf .bb-data`.\n\n##  Deploying to AWS\n\nWhen you're ready to go live:\n\n\n\n    npm run sandbox          # Ephemeral backend on AWS (2-3 minutes)\n    npm run deploy           # Production with S3 + CloudFront hosting\n    npm run sandbox:destroy  # Tear down when done\n\n\nNo AWS experience required. The same code you tested locally runs on DynamoDB, Lambda, API Gateway, AppSync, and CloudFront without changes.\n\n##  Conclusion\n\nWe built a full-stack World Cup bracket picker with authentication, structured data, realtime updates, an AI agent, and scheduled background jobs. Every block ran locally with zero AWS credentials. The source code is on GitHub (full implementation on `main`, frontend-only starting point on `mock`).\n\nTo get started with AWS Blocks:\n\n  * AWS Blocks product page\n  * Getting started guide\n  * AWS Blocks on GitHub\n\n",
  "title": "Building a World Cup Bracket Picker with AWS Blocks"
}