Millions of birders worldwide rely on Merlin and eBird to identify birds and track sightings. As a newbie birder, I always want to see my favorite birds more. However, there is no straightforward way in both apps to let a user find the best PLACE and TIME to see a particular bird. In this blog post, I am going to build this user-friendly feature so that you can enter a bird, set your location, choose a distance and a time window, and it tells you where to go on a map and in a list.
The full source code of this project can be found here.
Project goals
Let’s imagine you flip through guide books or some photo works, and you are mesmerized by a particularly beautiful bird (say for example, short-eared owl). Where to see it? Unfortunately, you have a busy schedule so you can only drive less than 1 hour to see it. Now since you value your precious time, you want to find a location where you are guarantee to see it (preferrably a lot of them!) What to do?

You open your trusty Merlin app. You click Explore. It shows all species near your location.
You type “short-eared owl” in the search box and click it in the list. Unfortunately, you only see more details of the bird like description, sounds and range. You don’t even know exactly WHEN and WHERE did people see it.

Not easy to give up, you switch to eBird. Again you click Explore and flip the search from hotspots to species.

You then enter the same search and got the sightings! Finally!

Now you begin to have some idea of where to go, if you want to spot short-eared owls nearby. But now you wonder if the low counts (1 in all sighting) is due to migratory or seasonal pattern. So you decide to filter by another time.

You hit the filter button on the upper right and see this.
Too bad the time period maxes out to 30. So if you wonder if you can see it more in another season, or check its annual patterns, you are out of luck.
Wouldn’t it be easier if you can directly enter any species, set a location radius and an arbiturary time window, and the app can directly tell you where to go for maximum sightings and counts? Like you can go to a place where the bird has been seen MANY times, and preferably in flocks?
This is what I aim to solve with my project.
Another benefit of my app is that you are not bound by the region defined by eBird when doing a search. For a new birder, what really matters is the location you want to start looking around. For example, if you want to find birds around “Spencer Island”, a popular spot in Puget Sound, your only hope is to go to Explore Hotspots and search from there as this spot is not defined in “Explore Regions”. As I use geocoding/browser geolocate to resolve a location and search in the app, the user can start the search in any desired location.
Lastly, you are not bound by the species list for a particular area. For example, in eBird, if you want to search for Turfed Puffin, you have to go to filters and change locations first. I want to make it easier so that if you come across a new or interesting bird in book/online, you can still find sightings even if it is outside your area.
High-level architecture
Technical stack used:
| Layer | Option | Why |
|---|---|---|
| Frontend | Cloudflare Pages | Static HTML/JS, all bundled in same project with logging, fast global edge for static assets |
| Backend | Cloudflare Pages Functions | Fast global edge for functions, seamless integration with Cloudflare KV cache |
| Mapping | Leaflet + OSM | Free tiles, stable, easy clustering |
| Geocoding | Nominatim | Free API to convert location to Lat/Long. |
| Caching | Cloudflare KV | Simple key-value edge cache to make resolving taxonomy name faster |
| Styling | Tailwind CSS | For rapid, clean UI development |
| Rate-limiter | Cloudflare Web Application Firewall | Prevent single user or bot from overloading system |
- Frontend: simple static HTML with Tailwind + JS.
- Autocomplete: use eBird taxonomy data (stored in edge Cloudflare KV) to look up species name with fuzzy search.
- Location: You can type “Central Park, NY” (handled by Nominatim Geocoding API) OR click “GPS” (handled by browser Geolocation).
- Map: Leaflet + OpenStreetMap for mapping markers and taking care of zooming and radius.
- Autocomplete: use eBird taxonomy data (stored in edge Cloudflare KV) to look up species name with fuzzy search.
- Backend: Serverless functions (Cloudflare Pages Functions) to proxy eBird/GBIF API, do species lookup, date filtering, and caching to KV.
- Cache: Cloudflare KV to complement the stateless nature of serverless functions. Store species taxonomy for faster up name lookup.
- Geocoding: Nominatim (OpenStreetMap) with JSON output.
- Data sources:
- eBird API: recent nearby observations, nearest observations of a species, and taxonomy for species names/ codes used to power common-name → species-code mapping. Requires free API key. > Note: eBird API restrict how far back you can query observations. The “recent observations” endpoint (
/data/obs/geo/recent/{species}) only allow a maximum of 30 days. To access older data, we have to resort to the “historic observations on a date” endpoint (/data/obs/region/historic/{date}), which only supports single dates, not arbitrary ranges. Plus it returns a list of all species seen on that date in the region, which requires further post-processing. This is very clumsy, thus leading us to the next API. - GBIF Occurrence API: (
/v1/occurrence/search) for observations over 30 days old. No API key required. > I have thought about downloading the EBD or EOD datasets and slice and dice it for this app. But the shear size (even the smaller EOD dataset is over 120G when filtered down to US 2023-2025 data) rules against this idea.
- eBird API: recent nearby observations, nearest observations of a species, and taxonomy for species names/ codes used to power common-name → species-code mapping. Requires free API key. > Note: eBird API restrict how far back you can query observations. The “recent observations” endpoint (
- Security:
- API key storage: backend-only environment variables; never expose to browser.
User journey
- Frontend: User types a common bird name (with autocomplete)
- Backend: Map common name → eBird species code from cached taxonomy and return it.
- Frontend: User chooses location (auto or manual),
- Backend: Resolve lat/lng: geocode if user typed a place; otherwise use browser coordinates.
- Frontend: User picks distance in miles (default 15) and date range and clicks Search.
- Backend:
- Fetch observations: recent nearby observations filtered by species within the radius, or use “nearest observations of a species” when sparse.
- Normalize results standardize fields (date, location, lat/lng, count, etc.)
- Date filtering: Use eBird recent endpoint if required date range is within last 30 days, otherwise, use GBIF endpoint.
- “Last X days” via eBird’s recent endpoint by using a days-back parameter and server-side filters.
- “Specific month (e.g., Nov 2024)” by filtering normalized results by obsDt month/year.
- “Specific date range (e.g., 12/1/2024-12/15/2024)”
- Fetch observations: recent nearby observations filtered by species within the radius, or use “nearest observations of a species” when sparse.
- Frontend: Renders
- A map with markers showing sighting date, count and species name
- A list sorted by sighting date, showing location and count.
Project structure and routing
File layout:
- Project root stores static HTML.
- Pages Functions live under
functions/as Cloudflare Pages Function uses file-based routing, which automatically treats any files infunctions/as API endpoints and binds them under your Pages domain (e.g.,functions/api/observations.ts→/api/observations).
birding-planner/
├─ index.html # frontend
├─ main.js # all client‑side logic (map setup, autocomplete, search, grouping)
├─ style.css # extra styles beyond Tailwind
├─ functions/
└─ api/
├─ search_bird.ts # taxonomy autocomplete
├─ geocode.ts # Nominatim place → lat/lng
└─ sightings.ts # observations with filters
Step-by-step implementation
1. Prerequisites
- Node.js 18+
- Cloudflare account
- eBird API key
- Github account if auto build/deploy is desired
2. Create Project
# Install Cloudflare Wrangler CLI
npm install -g wrangler
# Create a new project directory
mkdir birding-planner && cd birding-planner
# Create directory for Cloudflare Pages Functions
mkdir functions3. Create and bind a KV namespace
Cloudflare Pages Functions are stateless and each request runs in isolation. That means we can’t rely on a true “in‑memory cache” across requests, as any variable we set will vanish after the request finishes.
To mitigate this, we can use Cloudflare KV to persist data across requests in a distributed and durable way. Setting up a Bindings for KV enable our Pages Functions to integrate with KV seamlessly, configured per environment in the dashboard.
To search sightings, we need to use eBird taxonomy data to map the common names user input into eBird’s species codes. Storing the taxonomy data in KV avoids repeated cold starts fetching it, improving both efficiency and performance while reducing API calls to eBird. To do so,
From your project root:
wrangler kv namespace create birds-kvThis creates a namespace named
birds-kvwith the namespace name and id.Bind the namespace in Pages dashboard
Go to Workers & Pages →
birding-planner-> Settings → Bindings, click + Add -> KV Namespace- Variable name: :
EBIRD_KV - KV namespace: : Use the dropdown to select the
birds-kvnamespace you just created.
Note: The Settings page has a Choose environment setting on top for Preview and Production. If you want to enable identical settings in both environment, make sure to setup both.
- Variable name: :
4. Configure environment variables
In the same Settings page, go to Variables and Secrets: - Type: Plaintext - Name: EBIRD_API_KEY - Value: your eBird API key
5. Create Backend API routes (serverless functions)
We will implement 3 routes: - GET /api/species/search_bird?q=…
- Returns {speciesCode} suggestions from eBird taxonomy cached in KV. - GET /api/sightings?code=…&lat=…&lng=…&dist=…&daysBack=…&month=…
- Convert miles → km if needed.
- Call eBird nearby/nearest species endpoints.
- Normalize fields: {obsDt, comName, locName, lat, lng, howMany, subId}.
- returns {center, sightings:[...]}. - GET /api/geocode?q=…
- Proxy to geocoding provider and return {lat, lng, placeName}.
Taxonomy lookup with autocomplete
The eBird taxonomy provides the species list needed to map common names to species codes for API querying.
In this function, we will
- Seed taxonomy: fetch eBird taxonomy once and store in KV; build an index of {comName, sciName, speciesCode}. Subsequent request will read taxonomy from KV via env.EBIRD_KV.get to speed up the process. - Fuzzy search: on common names to power autocomplete. Returns top species matches from cached taxonomy.
- send speciesCode lookup result to sightings.ts to search for observations.
The full code can be found in my Github repo.
Geocoding
In this function, we will use Nominatim API to resolve a place to lat/lng and return { lat, lng, placeName }: - Auto detect: navigator.geolocation.getCurrentPosition with a timeout and graceful fallback.
- Manual input: input → geocode → pick first top-quality result.
Nominatim requires a User-Agent header with descriptive string that includes a contact email or URL. Without it, Nominatim returns an HTML error page. They also enforce ~1 request/sec per IP, or else you will get 403 error.
The full code can be found in my Github repo.
Search sightings
This function fetch nearby observations for a species around lat/lng, within a distance and time window.
It supports: - Distance: numeric input with default 15 miles; validate and cap at 50 (per eBird limit). - Custom date: user can choose from one of the following filters: Last X days daysBack (e.g., 7, 30, 90), a specific month (e.g., 2024-11), or a custom date range for server-side filtering of normalized results.
It queries data from 2 APIs - If the query is ≤30 days old, use the eBird API - https://api.ebird.org/v2/data/obs/geo/recent/${code} where code is the 6 character taxonomy code mapped by KV lookup with common name provided by user. - If the query is >30 days old, switch to GBIF API: - Look up the species’s nubKey (taxonKey) via GBIF’s /species/search endpoint. For example: https://api.gbif.org/v1/species/search?q=${encodeURIComponent(comName)}&rank=SPECIES&limit=1 where comName is the common name the user specifies in the UI.
- Query GBIF /occurrence/search endpoint with that taxonKey, bounding box/radius, date range, and datasetKey for eBird. For example:
```
https://api.gbif.org/v1/occurrence/search?\
taxonKey=XXXX&\
datasetKey=4fa7b334-ce0d-4e88-aaae-2e0c138d049e&\
decimalLatitude=47.45,47.78&\
decimalLongitude=-122.55,-122.10&\
eventDate=2024-12-01,2024-12-03&\
hasCoordinate=true&\
radius=15&\
limit=300
```
> Note: There is a delay in eBird records appearing in GBIF as eBird only uploads its core dataset to GBIF annually. Therefore, there will be a gap of no data between the last 30 days and your chosen date range, depending on when you make the query.
Results are first normalized (since we are handling two types of JSON files), then post-filtered and sorted by descending date.
Guardrails: - a haversine filter after fetching to enforce the radius user enters. This guarantees sightings are truly within the required circle. This is because eBird sometimes includes hotspots whose centroid is inside the circle but whose actual location feels “outside.”
- clamp logic for distance and daysBack, preventing invalid values.
The full code can be found in my Github repo.
6. Create Frontend UI
Page layout index.html
In this file, we structure the page layout: input section, map container, results list with the following UI elements: Input: * Bird name: Autocomplete activates after 3 characters * Location: “Use current location” button plus manual place input resolved via Nominatim. * Date filters: numeric input for “last X days” and date picker for “YYYY‑MM” month and Date Range.
Result: Search input section collapses to focus on results. In addition, a Summary header provides clear context for the results with species name, distance, date window & location. * Map view:
- Viewport: center on lat/lng, set bounds from distance circle.
- Pins: clustered markers; on click, show popup with date, location name, count and species name.
- Circle: draw the distance circle to make scope explicit.
* List view:
- Sort: Group sightings by location, sort by date, collapsible details
- Highlight selected location on map when list item is tapped.
The full code can be found in my Github repo.
style.css
Implement extra layout:
/* Extra tweaks beyond Tailwind */
.group-header {
cursor: pointer;
}
.group-details {
margin-top: 5px;
padding-left: 15px;
font-size: 0.9em;
}
/* Use tabular numbers for aligned date/time columns */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
/* Ensure long location names don’t break layout */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}Frontend logic main.js
In this file, we implement - search form submission to the backend route - fetch sightings from /api/sightings - transform and group sightings by location and date - render species summary, map with pushpins, and collapsible list.
- store location markers in a dictionary keyed by locName and subId. - Click group header → zooms map to the location marker and opens popup. - Click individual sighting → zooms to that specific marker (if subId available), otherwise falls back to location marker.
The full code can be found in my Github repo.
7. Deploy to production
We can use wrangler pages project create bird-nearby to create a new Cloudflare Page project, then deploy it with wrangler pages deploy ..
Or for continuous delivery, we can connect our Pages project to GitHub so that Cloudflare can automatically build and deploy whenever you push to the branch you’ve designated as “Production” or “Preview.”
Configure automatic build & deploy
Push source to Github Create a Github repo online, and check in your project files
git init git branch -M main git add -A git commit -m "initial commit" git remote add origin https://github.com/<YOUR_USER_NAME>/birding-planner git push --set-upstream origin mainConfigure repository access for Cloudflare Setup Cloudflare Pages app on GitHub. https://github.com/apps/cloudflare-workers-and-pages
Connect Pages project to GitHub
- In the Cloudflare dashboard, go to Workers & Pages → Create Application -> Looking to deploy Pages? Get started
- Choose Import an existing Git repository.
- Select your Github account and repository and click Begin Setup.
- In the Set up builds and deployments page, confirm
- project name
- Production branch: the repo branch that will trigger live production deployment
- Cloudflare will often auto-fill the Build settings (e.g., Build command and Build output directory) based on your project’s framework (like Next.js, Gatsby, etc.). Review and adjust these if necessary.
- Click Save and Deploy. Cloudflare will then build and deploy automatically whenever you push to that branch.
This deploys both our static assets (index.html) and Functions (/functions/api/… endpoints).
Once published, our functions will be compiled and available at
/api/...on our Pages domain, and KV bindings will be live as configured in the dashboard. Cloudflare automatically routes:- /index.html → our frontend
- /api/search_bird → functions/api/search_bird.ts
- /api/geocode → functions/api/geocode.ts
- /api/sightings → functions/api/sightings.ts
you can keep using Wrangler for local testing by
wrangler pages dev
Configure Production vs. Preview
In the project’s Settings page, you can configure the repo branch used for either Production or Preview in General/Production branch.
8. Rate limit traffic
One last thing, since the free eBird and Geocode APIs rely on courtesy, we should meter our usage to avoid bombarding their endpoints with malicious traffic.
In Cloudflare, we can implement rate-limiting in 2 different approach:
directly in the dashboard using the built-in Web Application Firewall (WAF) to create a rule that applies rate limiting to all requests matching a specific pattern (e.g., IP or request path). This approach enforces the limit at the network edge, stopping abusive traffic before it even hits our function logic, which saves on function invocations and provides better protection. Since your primary goal is to rate limit all routes, using Cloudflare’s WAF Rate Limiting Rules (Option 1 in the initial response) is the most robust way and requires no changes to your wrangler.toml or code, avoiding this configuration issue entirely.
programmatically via the rate-limiting API (using a Pages Functions Middleware), or by implementing a Cloudflare Durable Object to store visit data. This method offers more granular control over the logic, such as using a custom key (like an API key or user ID) instead of just the client’s IP.
For simplicity, let’s try the first approach to rate limit all routes. I will explore the 2nd approach in a future post instead.
1. Determining the Zone
The Cloudflare WAF Rate Limiting rules are Zone-level settings, meaning you must select the main domain (Zone) under which your Pages project’s custom domain is configured.
If you haven’t yet set up a custom domain for your Pages project, the only domain you have is the default
*.pages.devdomain, and rate limiting rules cannot be applied to the sharedpages.devdomain. You must add a custom domain (which creates a Zone if you add an apex domain) to use Zone-level WAF features.
Here is a step-by-step guide to clarify the relationship and find the Zone you need.
- Cloudflare Account: The top-level container.
- Zone: The next level down. A Zone is essentially a domain name (like
example.com) that you have added to Cloudflare and whose DNS is being managed by Cloudflare. - Pages Project: A separate service deployed on your Account that hosts your code. When you set up a custom domain such as
abc.example.comin Pages, Cloudflare automatically creates a DNS record (usually a CNAME) forabcthat points to your Pages project. This record lives inside theexample.comZone. Any request toabc.example.compasses through the WAF features of the parentexample.comZone.
To find your zone:
- Log in to the Cloudflare Dashboard.
- Navigate to Workers & Pages > Overview or Pages.
- Select your specific Pages Project.
- Go to the Custom domains tab in the Pages project settings.
- Look at the custom domain you have configured (e.g.,
www.example.comor justexample.com). - The Zone you need for the WAF rules is the root domain of that custom domain (e.g.,
example.com).
2. Applying the Rate Limit Rule
Once you know the root domain (Zone), you can proceed with the rate limiting setup:
- Select the Zone: In the Cloudflare dashboard, first click on
example.com(the root domain) to enter its settings. - Navigate to WAF: Go to Security > Security Rules > + Create Rules > Rate Limiting Rules.
- Create Your Rule:
Rule name: Choose a descriptive name, like
Rate Limit All Functions.When incoming requests match…: If you are using a subdomain for your Pages project, use an expression to ensure the rule only applies to traffic hitting your Pages subdomain and targeting your function routes.
Example Expression to Target Only the Pages Subdomain:
Instead of just checking the URI path, you would combine the
http.hostfilter:Field Operator Value Logic http.hostequalsabc.example.comAND http.request.uri.pathstarts with/api/By selecting the
example.comZone and using this specific filter, your rate limit will only be active for requests directed toabc.example.com/api/...and will not affect any other subdomains (likeblog.example.com) or the root domain (example.com) itself.With the same characteristic: Select one or more characteristics to define the counter.
IP Addressis the most common choice to rate limit individual users/clients.
When rate exceeds…: Set how many requests you allow within a time window
- Requests: Enter the maximum number of requests (e.g.,
100). - Period: Enter the time period (e.g.,
60seconds).
- Requests: Enter the maximum number of requests (e.g.,
Then take action:
Choose action: Select Block or Challenge (e.g., a CAPTCHA).
For duration: Set how long the client should be blocked/challenged for after exceeding the limit (e.g.,
600seconds / 10 minutes).
- Click Save.
This rule will now apply rate limiting based on the configured limits to all requests matching your expression.