Pagination
Introduction
Both the REST API and GraphQL use cursor-based pagination. Each response includes a pagination object. When there are more posts, it contains a nextCursor. Send that cursor back to get the next page.
Only Premium keys have more than one page. Free keys get their 6 most recent posts in a single response.
Free and Premium
| Free | Premium | |
|---|---|---|
| Posts you can read | The 6 most recent | Every post Feedframer has stored for the account |
| Default page size | 6 | 100 |
| Maximum page size | 6 (larger values give 6) | 100 (larger values are an error) |
| Further pages | None | Follow nextCursor |
On Free, pagination always has nextCursor: null, prevCursor: null and hasMore: false. A cursor sent with a free key is ignored and you get the same posts again. Filters and sorting apply within the 6 most recent posts. If you ask a free key for page[size]=3, you get 3 posts and no way to reach the other 3, so leave page[size] out on Free.
Pagination loops written for Premium work on Free without changes: they stop after the first page because hasMore is false.
REST API
Page size
page[size] sets how many posts come back per page. It must be a whole number from 1 to 100. Leave it out to get your plan's maximum: 6 on Free, 100 on Premium.
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&page[size]=6'
Pass -g to curl so it doesn't treat the square brackets as a pattern.
The pagination object
{
"username": "your_username",
"posts": [],
"pagination": {
"nextCursor": "eyJwdWJsaXNoZWRfYXQiOiIyMDI2LTAxLTE1IDE4OjMwOjAwIiwiaWQiOjQyLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9",
"prevCursor": null,
"hasMore": true,
"perPage": 100
}
}
(Profile fields and posts are left out here. See REST API for the full response.)
| Field | Type | Notes |
|---|---|---|
nextCursor |
string or null | Cursor for the next page. null on the last page, and always null on Free. |
prevCursor |
string or null | Cursor for the previous page. null on the first page, and always null on Free. |
hasMore |
boolean | true when there is a next page, which means nextCursor is not null. Always false on Free. |
perPage |
integer | The page size used: your page[size], capped at your plan's limit. |
There is no total count and no page number. Cursors are opaque strings: pass them back unchanged.
Fetching the next page
Send nextCursor as page[cursor]:
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&page[cursor]=NEXT_CURSOR'
cursor works as an alias for page[cursor]:
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&cursor=NEXT_CURSOR'
Cursors are made of characters that are safe in a URL, but encoding them anyway (as the examples below do) does no harm.
Going back a page
Send prevCursor as page[cursor] to get the page before the one you're on:
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&page[cursor]=PREV_CURSOR'
When you go back, the response has a nextCursor again, so you can move in either direction.
Rules for cursors
- Keep the same
filter[...]andsortvalues on every page. A cursor marks a position in one particular ordering. Using it with different filters or a different sort gives unpredictable pages. - You can change
page[size]between pages. The cursor marks where the next page starts, not how big it is. - An unrecognised cursor is ignored. You get the first page, not an error.
- The last page has
hasMore: falseandnextCursor: null. It may hold fewer posts thanperPage.
Fetch every post
Each loop below requests pages until hasMore is false. On Premium it collects every stored post; on Free it stops after the first response with 6 posts. Each checks for errors: every error response has an errors array with a code.
JavaScript:
const apiKey = 'YOUR_API_KEY';
async function fetchAllPosts() {
const posts = [];
let cursor = null;
do {
const url = new URL('https://feedframer.com/api/v1/me');
url.searchParams.set('api_key', apiKey);
if (cursor) {
url.searchParams.set('page[cursor]', cursor);
}
const response = await fetch(url);
const body = await response.json();
if (!response.ok) {
throw new Error(`${body.errors[0].code}: ${body.errors[0].detail}`);
}
posts.push(...body.posts);
cursor = body.pagination.hasMore ? body.pagination.nextCursor : null;
} while (cursor);
return posts;
}
const posts = await fetchAllPosts();
console.log(`Fetched ${posts.length} posts`);
PHP:
<?php
$apiKey = 'YOUR_API_KEY';
$cursor = null;
$allPosts = [];
// ignore_errors lets us read the JSON body of 4xx and 5xx responses
$context = stream_context_create(['http' => ['ignore_errors' => true]]);
do {
$params = ['api_key' => $apiKey];
if ($cursor !== null) {
$params['page'] = ['cursor' => $cursor];
}
// http_build_query turns ['page' => ['cursor' => ...]] into page[cursor]=...
$url = 'https://feedframer.com/api/v1/me?' . http_build_query($params);
$data = json_decode(file_get_contents($url, false, $context), true);
if (isset($data['errors'])) {
$error = $data['errors'][0];
throw new RuntimeException("{$error['code']}: {$error['detail']}");
}
$allPosts = array_merge($allPosts, $data['posts']);
$cursor = $data['pagination']['hasMore'] ? $data['pagination']['nextCursor'] : null;
} while ($cursor !== null);
echo 'Fetched ' . count($allPosts) . " posts\n";
Python:
import requests
api_key = 'YOUR_API_KEY'
cursor = None
all_posts = []
while True:
params = {'api_key': api_key}
if cursor:
params['page[cursor]'] = cursor
response = requests.get('https://feedframer.com/api/v1/me', params=params)
data = response.json()
if not response.ok:
error = data['errors'][0]
raise RuntimeError(f"{error['code']}: {error['detail']}")
all_posts.extend(data['posts'])
if not data['pagination']['hasMore']:
break
cursor = data['pagination']['nextCursor']
print(f'Fetched {len(all_posts)} posts')
A "Load more" button
To show a page at a time, keep the last nextCursor and request the next page when the visitor asks for it:
const apiKey = 'YOUR_API_KEY';
let nextCursor = null;
async function loadPage() {
const url = new URL('https://feedframer.com/api/v1/me');
url.searchParams.set('api_key', apiKey);
url.searchParams.set('page[size]', '6');
if (nextCursor) {
url.searchParams.set('page[cursor]', nextCursor);
}
const response = await fetch(url);
const body = await response.json();
if (!response.ok) {
throw new Error(body.errors[0].code);
}
nextCursor = body.pagination.nextCursor;
return {
posts: body.posts,
// Hide the button when this is false. It is always false on Free.
hasMore: body.pagination.hasMore,
};
}
A page size of 6 suits both plans: a free key gets its 6 posts and hasMore: false, and a premium key gets 6 at a time.
GraphQL API
The GraphQL me query uses the same cursors.
| Argument | Notes |
|---|---|
first |
Posts per page. Defaults to your plan's limit and is capped at it: 6 on Free, 100 on Premium. Larger values are reduced to the limit, not rejected. |
cursor |
pagination.nextCursor or pagination.prevCursor from a previous response. Premium only. |
page |
Deprecated and ignored. Use cursor. |
The pagination field has the same four fields as in REST: nextCursor, prevCursor, hasMore and perPage.
Basic query
query {
me(first: 6) {
posts {
id
caption
mediaUrl
thumbnailUrl
type
}
pagination {
nextCursor
prevCursor
hasMore
perPage
}
}
}
Next page
Pass nextCursor as the cursor argument, ideally through a variable:
query Feed($cursor: String) {
me(first: 6, cursor: $cursor) {
posts {
id
caption
}
pagination {
nextCursor
hasMore
}
}
}
Send the query with {"cursor": null} as its variables for the first page, then with the nextCursor you received.
Fetch every post
const apiKey = 'YOUR_API_KEY';
const query = `
query Feed($cursor: String) {
me(cursor: $cursor) {
posts { id type caption publishedAt }
pagination { nextCursor hasMore }
}
}
`;
const posts = [];
let cursor = null;
do {
const response = await fetch(`https://feedframer.com/graphql?api_key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { cursor } }),
});
const body = await response.json();
if (!response.ok) {
// 401: missing or invalid API key, in the REST error format
throw new Error(body.errors[0].code);
}
if (body.errors) {
throw new Error(body.errors[0].message);
}
const { me } = body.data;
posts.push(...me.posts);
cursor = me.pagination.hasMore ? me.pagination.nextCursor : null;
} while (cursor);
console.log(`Fetched ${posts.length} posts`);
Combining with filters
Pagination works with every filter and sort option. Keep them the same on every page.
REST:
# First page of image posts, 50 at a time (Premium; a free key gets the images among its 6)
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&filter[type]=IMAGE&page[size]=50'
# Next page: same filter, plus the cursor
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&filter[type]=IMAGE&page[size]=50&page[cursor]=NEXT_CURSOR'
GraphQL:
query ImagePosts($cursor: String) {
me(type: "IMAGE", first: 50, cursor: $cursor) {
posts {
id
mediaUrl
}
pagination {
nextCursor
hasMore
}
}
}
See Filtering & Sorting.
Errors
An invalid page[size] returns HTTP 422:
{
"errors": [
{
"status": "422",
"code": "VALIDATION_ERROR",
"title": "Invalid Parameter",
"detail": "The page[size] field must not be greater than 100.",
"source": { "parameter": "page[size]" }
}
]
}
The other messages for page[size] are The page[size] field must be at least 1. and The page[size] field must be an integer. See Error Handling.
Best practices
- Leave
page[size]out unless you need smaller pages. The default is the most your plan allows, so you make the fewest requests. - Check
hasMore, not the number of posts. A page can be full and still be the last one. - Refresh any copy you keep. Feedframer fetches new posts daily on Free and hourly on Premium. In production, API responses are also cached at Cloudflare's edge for an hour, so repeat requests for the same URL are served from that cache. See Caching & Performance.
- There are no rate limits.
Next steps
- Filtering & Sorting: filter the posts you page through
- REST API: the full REST reference
- GraphQL API: the full GraphQL reference