---
title: "Answer an API route"
audience: "anyone whose app needs endpoints a script or another service can call"
description: "You'll write a JSON API with a GET and a checked POST, answer a plain text download with your own Response and wrap every API call in middleware…"
snippet_caption: "app/routes/api/notes/route.ts"
snippet: "export async function GET({ session }: Ctx<\"/api/notes\">) {\n  return { notes: session.notes, count: session.notes.length };\n}"
---

import { Install } from "@src/ui/Install";

You'll write a JSON API with a GET and a checked POST, answer a plain text download with your own `Response` and wrap every API call in middleware. The handlers see the same session, identity and services as a page.

<Install captured="0.x" />

Start from a fresh app: `fsr new notes`. The notes live in the session, so there is nothing else to set up.

## Store the notes in the session

```ts title="app/schemas/session.ts"
export interface Session {
  notes: string[];
}

export const defaults: Session = {
  notes: [],
};
```

## Write a GET handler

A directory holds a `route.ts` in place of a page. Its exports named `GET`, `POST`, `PUT`, `PATCH` or `DELETE` are handlers. The host matches the method and the path before any page and answers with the returned value as JSON:

```ts title="app/routes/api/notes/route.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function GET({ session }: Ctx<"/api/notes">) {
  return { notes: session.notes, count: session.notes.length };
}
```

```shell-session
$ curl -i http://127.0.0.1:3000/api/notes
HTTP/1.1 200 OK
content-type: application/json

{"notes":[],"count":0}
```

A directory holds a page or a handler, never both.

## Take input with a POST

Declare the input:

```ts title="app/schemas/note.ts"
export interface NewNote {
  text: string;
}
```

Write the handler as an `action<T>` and the host checks the body against `T` before your code runs. Add it to the same file:

```ts title="app/routes/api/notes/route.ts"
import { action, after, fail } from "@snapfire/fsr/host";
import type { ActionCtx, Ctx } from "@snapfire/fsr/host";
import type { NewNote } from "@schemas/note";

export async function GET({ session }: Ctx<"/api/notes">) {
  return { notes: session.notes, count: session.notes.length };
}

export const POST = action(async ({ input, session }: ActionCtx<NewNote>) => {
  const text = input.text.trim();
  if (text === "") fail("invalid", "a note needs some text");
  session.notes = [...session.notes, text];
  const count = session.notes.length;
  after(() => {
    console.info(`note ${count} saved`);
  });
  return { notes: session.notes, count };
});
```

A handler written as a plain function gets `input` as whatever the request carried, unchecked.

Post a note with a cookie jar, so the second call reads the session the first one wrote:

```shell-session
$ curl -c jar -b jar -H 'content-type: application/json' -d '{"text":"buy milk"}' http://127.0.0.1:3000/api/notes
{"notes":["buy milk"],"count":1}
$ curl -c jar -b jar -H 'content-type: application/json' -d '{"text":"call Sam"}' http://127.0.0.1:3000/api/notes
{"notes":["buy milk","call Sam"],"count":2}
$ curl -c jar -b jar http://127.0.0.1:3000/api/notes
{"notes":["buy milk","call Sam"],"count":2}
```

A body that does not match `NewNote` never reaches your code. A `fail` sets the status by its kind, so `invalid` is a 400:

```shell-session
$ curl -i -H 'content-type: application/json' -d '{"txt":"x"}' http://127.0.0.1:3000/api/notes
HTTP/1.1 400 Bad Request
content-type: application/json

{"kind":"invalid","message":"input: missing field `text`"}
$ curl -i -H 'content-type: application/json' -d '{"text":"  "}' http://127.0.0.1:3000/api/notes
HTTP/1.1 400 Bad Request
content-type: application/json

{"kind":"invalid","message":"a note needs some text"}
```

A JSON call is not checked for a CSRF token. A form post is, so posting the same handler as a form without `_csrf` answers `403` with `csrf verification failed`.

## Do work after the response

`after(fn)` runs `fn` once the response is written, so the caller does not wait on it. The two posts above logged:

```shell-session
INFO fsr::console: note 1 saved
INFO fsr::console: note 2 saved
```

The function takes nothing and reads the names around it as they stood when `after` was called. It runs whether the body went on to return or to fail. It cannot write the session, since the cookie has already gone out with the response. The build refuses one that tries:

```shell-session
routes/api/notes/route.ts:14:3: a session write inside `after`, which runs once the response and its cookie are sent
```

## Answer with a Response

Return a `Response` when JSON with a 200 is not what you want. `Response.json` sets the status:

```ts title="app/routes/api/notes/latest/route.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function GET({ session }: Ctx<"/api/notes/latest">) {
  if (session.notes.length === 0) {
    return Response.json({ error: "no notes yet" }, { status: 404 });
  }
  return { text: session.notes[session.notes.length - 1] };
}
```

```shell-session
$ curl -i http://127.0.0.1:3000/api/notes/latest
HTTP/1.1 404 Not Found
content-type: application/json

{"error":"no notes yet"}
```

`new Response` answers anything else. A directory name can carry an extension, so `notes.txt` is a route of its own:

```ts title="app/routes/api/notes.txt/route.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function GET({ session }: Ctx<"/api/notes.txt">) {
  const body = session.notes.map((note, i) => `${i + 1}. ${note}`).join("\n");
  return new Response(`${body}\n`, {
    headers: { "content-type": "text/plain; charset=utf-8", "content-disposition": "attachment; filename=notes.txt" },
  });
}
```

```shell-session
$ curl -i -c jar -b jar http://127.0.0.1:3000/api/notes.txt
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-disposition: attachment; filename=notes.txt

1. buy milk
2. call Sam
```

## Wrap the API in middleware

`app/middleware.ts` runs before every request that is not a static file, handlers included. `await next()` runs the route and hands back its `Response`, which you can change before returning it:

```ts title="app/middleware.ts"
import type { MiddlewareCtx } from "@snapfire/fsr/host";

export async function middleware({ request, next }: MiddlewareCtx) {
  if (!request.path.startsWith("/api/")) return;
  if (request.path === "/api/v1/notes") return next(new Request("/api/notes"));
  const response = await next();
  response.headers.set("cache-control", "no-store");
  return response;
}
```

`request` carries `method` and `path`. Returning nothing lets a page request through untouched. `next(new Request("/api/notes"))` runs the route for another path under the same URL, so the old `/api/v1/notes` keeps answering. It returns straight away, so it gets no header. Every other API response now carries one:

```shell-session
$ curl -i -c jar -b jar http://127.0.0.1:3000/api/notes
HTTP/1.1 200 OK
content-type: application/json
cache-control: no-store

{"notes":["buy milk","call Sam"],"count":2}
$ curl -i -c jar -b jar http://127.0.0.1:3000/api/v1/notes
HTTP/1.1 200 OK
content-type: application/json

{"notes":["buy milk","call Sam"],"count":2}
```

## Call it from the page

A script on the page reaches the same handlers with `fetch`. The browser sends its cookie, so the handlers see the same session:

```ts
const saved = await fetch("/api/notes", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ text: "from the page" }),
}).then((r) => r.json());
```

Run in a fresh browser, that answers `{"notes":["from the page"],"count":1}`. A following `fetch("/api/notes/latest")` answers `{"text":"from the page"}`.

Next up: [390. Animate page changes with view transitions](https://snapfirers.com/fsr/learn/390-animate-page-changes-with-view-transitions).
