Lesson 10: Networking - strvcom/frontend-academy-2022 GitHub Wiki

Speaker: Dan Balarin

Resources


Theory

TCP, UDP

TCP is a connection-oriented protocol. First, it establishes a connection with the server, then it sends request and receives response. After each response, there is also sent confirmation message from the client.

UDP is a connection-less protocol. It sends requests and receives responses without establishing a connection.

That means if you have a slow internet connection, TCP will be way slower, because of the needed handshake, but you'll be guaranteed to receive all data.

TLS

TLS is a protocol that is used to encrypt data. It is used to protect data from eavesdropping and to ensure that data is not tampered with. It also requires a handshake to be done before data is encrypted and sent.

HTTP

HTTP is built on top of TCP and TLS, so for each host, there is a need for at least two handshakes. So if you are getting fonts from google and have some images loaded from your CDN you are already on four handshakes minimum.

  • HTTP 1.0: each request needs a separate connection -> lot of handshakes
  • HTTP 1.1: multiple files from the same host can be sent over the same connection, and can be requested in parallel
  • HTTP 2.0: parallel requests can be completed in no particular order of execution
  • HTTP 3.0: replaces TCP and TLS with UDP and QUIC, which is faster and more reliable and requires fewer handshakes

There are more differences in those versions, but these are the most important for us.

Methods

Each HTTP request must have a method, each serves different purpose in terms of allowed contained data and semantics.

  • CONNECT: establishes connection
  • DELETE: deletes resource
  • GET: gets resource
    • GET is the only one from the common methods that are cached by the browser by default*
  • HEAD: gets the same headers as GET request
  • OPTIONS: get a list of permitted methods for url/host
  • PATCH: updates resource (partial update)
  • POST: general-purpose method for manipulating data (used as create most of the time)
  • PUT: updates resource (full override)
  • TRACE: ping with message

*You can cache other requests as well via the Cache API if the browser supports it.

Status codes

For each response, you'll get a response status code that will give you more information about whether the request was successfully fulfilled or why it failed.

API

We will talk about API in two different contexts. First is API as some sort of server that provides you with needed information about entities. This is commonly referred to as backend API. And the second API is a set of functions/methods that you'll be provided by some library. We will use this terminology in the Fetch chapter a lot, so bear this in mind, to not be confused.

There are several ways of defining API (server), but the most used ones are REST and GraphQL, both are using JSON format for data exchange.

REST

It's a nonstandardized way of communication between server and client. Most of the time, the server defines endpoints for each entity as well as some other endpoints, like login, search, etc. Let's take a Eventio API as an example. The server is hosted on https://testproject-api-v2.strv.com/, this is the root URL. The API provides us with endpoints for two entities, Events and Users. Here is the list of endpoints for Events:

  • GET /events - returns list of events
  • POST /events - creates new event
  • GET /events/{id} - returns event with given id
  • PATCH /events/{id} - updates event with given id
  • DELETE /events/{id} - deletes event with given id

As you can see, the API is defined by url and method. It's nicely readable and easy to use.

Problems:

Under/Over fetching

The Eventio API gets you attendees in the list detail, but for sake of the example imagine the following:

Imagine that you need to get a list of attendees that would be interested in the event. That would most likely be exposed on GET /events/{id}/attendees. So if you want to get all the information for the detail page, you'd need to make two requests, detail of the event and a list of attendees. This problem is called under fetching, we are getting fewer data from one request than we really need to.

Now let's go the other way. Imagine that list of events also gets you a list of attendees for each event. But you don't need this information, because you are not showing it anywhere, but you basically get a huge list for every event. The bigger the response is, the slower it is as well obviously. This problem is called over fetching, we are getting more data from one request than we need to.

These problems directly affect the user, because the app needs to make more calls or is getting a huge data load that is unused.

Not typed

And the last problem of REST is that it's not standardized and is detached from the data type. So basically anyone can choose their own style of defining REST API and you can't even be sure of the type you are getting. This problem on the other hand doesn't affect the user, in a perfect world. But because we are humans, and humans made mistakes, there is a high chance of something being wrong, maybe some field is null even if it shouldn't be or anything, and if we are not prepared for it, the app will crash, thus affecting the user, in a more critical way.

GraphQL

GraphQL solves all of the problems mentioned above. It's a query language, so it's standardized, and strongly typed. The drawback is that it's a bit more complicated than REST. But with this steeper learning curve comes a lot of benefits. Unlike REST, GraphQL is exposed on a single endpoint, and the requests are made with the POST method. The body of the request contains a query that is then executed on the server and you get data in a structure according to the request query. To differentiate between queries that mutate data there are two types of queries, query, and mutation.

If we return to the example with Eventio, let's make query to get detail of event with attendees, just like we did with REST.

query GetEvent($id: ID!){
  event(id: $id){
    name
    description
    ...
    attendees{
      name
      ...
    }
  }
}

As you can see, we defined a query that if executed with an id of the event, will return the event with attendees. The result of this query is a structure that looks like this:

{
    "data": {
        "event": {
            "name": "Event name",
            "description": "Event description",
            "attendees": [
                {
                    "name": "Attendee name",
                },
                {
                    "name": "Attendee name",
                },
            ]
        }
    }
}

So you can change the query so it suits your needs. This solves the under and over-fetching problems of the REST.

But GraphQL comes with some problems on its own as well. The first of them comes by design.

Caching

Because every request is made via POST method and to the same endpoint, it's not cached by the browser. You can cache it by using the Cache API, but that's some additional work. Another workaround is to use persistent queries. What that means is that some queries are exposed on another endpoint, so with this example, we could have an endpoint like this:

  • GET /events/{id}

that would internally execute the query above.

And the second problem is backend only, so if you don't care, just skip the next section.

N+1 problem

Imagine scenario where you have events and attendees. Now let's execute the above query. In a naive world with some basic data retrieval using some ORM, this would create SQL query like this:

SELECT * FROM events;

SELECT * FROM users WHERE id IN (1);

SELECT * FROM users WHERE id IN (2);

SELECT * FROM users WHERE id IN (3);

SELECT * FROM users WHERE id IN (4);
So, for each attendee, it would create a separate select, which is highly inefficient. The name of the problem comes from the number of selects, there is one for events and N for every attendee.

gRPC

Cool method of data exchange, but needs HTTP/2, so if used, it's only on some internal applications. That means you will not meet it anywhere on the public web, but now you can say "I heard about that!".

SOAP

Very old protocol, standardized, powered by XML. It's used only in legacy applications.

Networking on a client-side

I will not cover the history here, check the recording for that.

Here is a table of pros and cons of Fetch API:

Pros Cons
Nice, easy API Environment support
Promise based Lack of onprogress event
Better error handling
No-cors support
Stream support
Cache API support

Before we dive into creating our own fetch wrapper, let's just do a quick overview of existing solutions. The most basic ones are ky, axios and superagent. Ky is wrapping fetch, axios and superagent are wrapping XHR. So the libraries come with the pros and cons similar to the table above. They provide you with easy to use API as well as some advanced features like request cancellation, automatic body parsing, interceptors and more.

And as for the advanced clients, I'll just quickly go over the most popular ones. First on the list is react-query. It has a lot of features, but the one that stands out is that it's query agnostic. You can use it for REST APIs as well as GraphQL ones.

Same goes for next candidate, SWR. It's made by Vercel, the same guys that stands behind Next.js. So it's main benefit is, that it's built mainly for Next.js, but overall it's less featured than react-query.

And last library worth mentioning is Apollo Client. It's a client for GraphQL, but it's a overall better choice for GraphQL than react-query. It has built in support for some GraphQL specific features, like persistent queries.

And what does it mean, "Advanced client"?

It provides functionalities like client-side caching, normalization, pagination, prefetching, query invalidation, state management, and more. React-query has nice comparison table on their website, check it out

Fetch

Let's start with naive implementation in useEvents hook.

  // Trigger the event loading.
  useEffect(() => {
    setIsLoading(true)
    const request = new Request('https://testproject-api-v2.strv.com/events')
    const response = await fetch(request)
    const data = (await response.json()) as Event[]
    setIsLoading(false)
    setData(data)
  }, [])

This code will however throw you an error. The reason is, that you can await only in async functions. But since useEffects return value is a callback called on unmount, it can't be async (because what if the component is unmounted before the async function is finished?). The solution to this is to wrap the async content into a function and call it in useEffect.

useEffect(() => {
    const loadEvents = async () => {
        setIsLoading(true)
        const request = new Request('https://testproject-api-v2.strv.com/events')
        const response = await fetch(request)
        const data = (await response.json()) as Event[]
        setIsLoading(false)
        setData(data)
    }
    void loadEvents() // void means that the promise is ignored on purpose
}, [])

Now we have no error, but we are still not fetching the data. Problem is that we have not set API key header. Let's fix that.

setIsLoading(true)
const request = new Request('https://testproject-api-v2.strv.com/events')
+ request.headers.append(
+   'APIKey',
+   'KEY_HERE'
+ )
const response = await fetch(request)
const data = (await response.json()) as Event[]

Hurray, we are fetching the data. But if we want to fetch data from another endpoint, we need to copy all that to a new hook. And if the key changes, we need to fix that in all of those places. Not very maintainable.

Let's abstract this code into some network provider.

We will create src/features/api/lib/network-provider.ts file, which will contain the abstraction. Let's start with just the API we want to use. We will start with mental exercise, what do we need? We want to abstract common part of url, let's call it baseUrl. Also, let's abstract the API key, so headers is another option. And last is some way to send any arbitrary data with the request. This data will be sent as JSON, so call it json in the options. And last part of the options is RequestInit which is an object that can be passed to Request.

type NetworkProviderOptions = RequestInit & {
  /**
   * Base URL to use for all requests
   */
  baseUrl?: string

  /**
   * Data to be stringified and sent with the request
   */
  json?: unknown

  /**
   * Headers to be sent with the request
   */
  headers?: Headers
}

class NetworkProvider {
  private readonly options: NetworkProviderOptions

  constructor(options: NetworkProviderOptions) {
    this.options = options
  }
}

export type { NetworkProviderOptions }

export { NetworkProvider }

To continue with this mental exercise, we need to make the request, right? Let's create a method called makeRequest that will do that.

class NetworkProvider {
    async makeRequest(url: string, options: NetworkProviderOptions) {
        const mergedOptions = NetworkProvider.mergeOptions(this.options, options)
        const {
            baseUrl,
            json: requestJson,
            ...requestOptions
        } = NetworkProvider.normalizeOptions(mergedOptions)

        // Set content-type header to application/json if not already set
        if (requestJson !== undefined) {
            requestOptions.body = JSON.stringify(requestJson)
            requestOptions?.headers?.set(
            'content-type',
            requestOptions?.headers.get('content-type') ?? 'application/json'
            )
        }

        // Create request with base url
        let request = new Request(
            baseUrl ? new URL(url, baseUrl).toString() : url,
            requestOptions
        )

        // Make request
        let response = await fetch(request)

        return response
    }
}

As you can see, we need to merge multiple options and normalize them. So that means we need two other methods, and since those are completely independent of the instance but it doesn't make sense to define those functions outside of the class, we will define them as static.

Phase 1 network-provider.ts
type NetworkProviderOptions = RequestInit & {
/**
* Base URL to use for all requests
*/
baseUrl?: string

/**
* Data to be stringified and sent with the request
*/
json?: unknown

/**
* Headers to be sent with the request
*/
headers?: Headers
}

class NetworkProvider {
    private readonly options: NetworkProviderOptions

    constructor(options: NetworkProviderOptions) {
        this.options = options
    }

    static normalizeOptions(options: NetworkProviderOptions) {
        if (!options.headers) {
        options.headers = new Headers()
        }
        if (!options.method) {
        options.method = 'GET'
        }
        return options
    }

    static mergeOptions(
        options: NetworkProviderOptions,
        newOptions?: NetworkProviderOptions
    ) {
        // This will override headers and interceptors, instead of joining them
        return {
        ...options,
        ...newOptions,
        }
    }

    async makeRequest(url: string, options: NetworkProviderOptions) {
        const mergedOptions = NetworkProvider.mergeOptions(this.options, options)
        const {
        baseUrl,
        json: requestJson,
        ...requestOptions
        } = NetworkProvider.normalizeOptions(mergedOptions)

        // Set content-type header to application/json if not already set
        if (requestJson !== undefined) {
        requestOptions.body = JSON.stringify(requestJson)
        requestOptions?.headers?.set(
            'content-type',
            requestOptions?.headers.get('content-type') ?? 'application/json'
        )
        }

        // Create request with base url
        let request = new Request(
        baseUrl ? new URL(url, baseUrl).toString() : url,
        requestOptions
        )

        // Make request
        let response = await fetch(request)

        return response
    }
}

export type { NetworkProviderOptions }

export { NetworkProvider }

Now we can proceed to the creation of the instance. We will put it in src/features/api/lib/client.ts file.

import { NetworkProvider } from './network-provider'

const api = new NetworkProvider({
  baseUrl: 'https://testproject-api-v2.strv.com/',
  headers: new Headers({ APIKey: 'KEY_HERE' }),
})

export { api }

And now we can use it. Let's refactor useEvents.ts to reflect this change.

useEffect(() => {
    const loadEvents = async () => {
        setIsLoading(true)
-       const request = new Request('https://testproject-api-v2.strv.com/events')
-       request.headers.append(
-           'APIKey',
-           'KEY_HERE'
-       )
-       const response = await fetch(request)
+       const response = await api.makeRequest('/events', { method: 'GET' })
        const data = (await response.json()) as Event[]
        setIsLoading(false)
        setData(data)
    }
    void loadEvents()
}, [])

Now let's go into one of the coolest things any networking library provides to you. That is interceptor/middleware/hook, it is several names for the same thing, but interceptor makes the most sense, so we'll stick to that. Interceptor, as the name suggests, is something that will intercept requests at various stages of the request lifecycle. The most common ones are before the request is sent and after the response is received. These interceptors are implemented as functions that are called before/after the request. The before request will modify the request, and the after request will modify the response. So let's start with types again.

type BeforeRequestInterceptor = (
  request: Request,
  options: NetworkProviderOptions
) => Request

type AfterRequestInterceptor = (
  request: Request,
  options: NetworkProviderOptions,
  response: Response
) => Response

type NetworkProviderInterceptors = {
  /**
   * Methods run before each request
   */
  beforeRequest?: BeforeRequestInterceptor[]

  /**
   * Methods run after each request
   */
  afterRequest?: AfterRequestInterceptor[]
}
type NetworkProviderOptions = RequestInit & {
  /**
   * Base URL to use for all requests
   */
  baseUrl?: string

+ /**
+  * Middleware to run before and after each request
+  */
+ interceptors?: NetworkProviderInterceptors

  /**
   * Data to be stringified and sent with the request
   */
  json?: unknown

  /**
   * Headers to be sent with the request
   */
  headers?: Headers
}

and modify all the methods accordingly

  static normalizeOptions(options: NetworkProviderOptions) {
    if (!options.headers) {
      options.headers = new Headers()
    }
    if (!options.method) {
      options.method = 'GET'
    }
+   if (!options.interceptors) {
+     options.interceptors = {}
+   }
+   if (!options.interceptors.beforeRequest) {
+     options.interceptors.beforeRequest = []
+   }
+   if (!options.interceptors.afterRequest) {
+     options.interceptors.afterRequest = []
+   }
    return options
  }
async makeRequest(url: string, options: NetworkProviderOptions) {
    const mergedOptions = NetworkProvider.mergeOptions(this.options, options)
    const {
      baseUrl,
      json: requestJson,
+     interceptors,
      ...requestOptions
    } = NetworkProvider.normalizeOptions(mergedOptions)

    // Set content-type header to application/json if not already set
    if (requestJson !== undefined) {
      requestOptions.body = JSON.stringify(requestJson)
      requestOptions?.headers?.set(
        'content-type',
        requestOptions?.headers.get('content-type') ?? 'application/json'
      )
    }

    // Create request with base url
    let request = new Request(
      baseUrl ? new URL(url, baseUrl).toString() : url,
      requestOptions
    )

+   // Run before request middleware
+   if (interceptors?.beforeRequest?.length) {
+     request = interceptors.beforeRequest.reduce(
+       (request, interceptor) => interceptor(request, options),
+       request
+     )
+   }

    // Make request
    let response = await fetch(request)

+   // Run after request middleware
+   if (interceptors?.afterRequest?.length) {
+     response = interceptors.afterRequest.reduce(
+       (response, interceptor) => interceptor(request, options, response),
+       response
+     )
+   }

    return response
  }

Voilà, we have working interceptors. Let's refactor client.ts to be more readable.

import type { BeforeRequestInterceptor } from './network-provider'
import { NetworkProvider } from './network-provider'

/**
 * Before request hook to append API Key header on all requests.
 */
const appendAPIKey: BeforeRequestInterceptor = (request) => {
  request.headers.append('APIKey', 'KEY_HERE')
  return request
}

const api = new NetworkProvider({
  baseUrl: 'https://testproject-api-v2.strv.com/',
  interceptors: {
    beforeRequest: [appendAPIKey],
  },
})

export { api }

Now is way more readable, almost like a sentence. api is an instance of NetworkProvider class, it has a base url for all requests and before each request, we append API Key. And now if we want to move from headers to cookies for API key, we will change the interceptor, but the "sentence" will stay the same.

Speaking of semantics, if we look into useEvents hook, it's not really semantically correct. Make request is nice, but it would be the same for all methods, only difference would be in options, not really readable. Let's change it to be more semantic.

  useEffect(() => {
    const loadEvents = async () => {
      setIsLoading(true)
-     const response = await api.makeRequest('/events', { method: 'GET' })
+     const response = await api.get('/events')
      const data = (await response.json()) as Event[]
      setIsLoading(false)
      setData(data)
    }
    void loadEvents()
  }, [])

And implement this change in network-provider.ts

  ...
  
  static mergeOptions(
    options: NetworkProviderOptions,
    newOptions?: NetworkProviderOptions
  ) {
    // This will override headers and interceptors, instead of joining them
    return {
      ...options,
      ...newOptions,
    }
  }

+ async get(url: string, options?: NetworkProviderOptions) {
+   return await this.makeRequest(url, {
+     ...options,
+     method: 'GET',
+   })
+ }

+ async post(url: string, options?: NetworkProviderOptions) {
+   return await this.makeRequest(url, {
+     ...options,
+     method: 'POST',
+   })
+ }

+ private async makeRequest(url: string, options: NetworkProviderOptions) {
- async makeRequest(url: string, options: NetworkProviderOptions) {
    const mergedOptions = NetworkProvider.mergeOptions(this.options, options)
    const {
      baseUrl,
      json: requestJson,
      ...

Also, we made makeRequest private to force using get and post methods.

We are finished 🎉. No, I'm just kidding, we still have something to do. Imagine you have some endpoints that require authentication. But in most cases, if you have an expired token and send it to UNauthenticated endpoint, you will get an error anyways. So it's better to have two API clients to differentiate, use one for authenticated requests and one for unauthenticated ones. But both of those clients will be almost the same, one will have an additional interceptor, so it would be nice to have some way to extend the client.

Let's implement that in network-provider.ts

class NetworkProvider {
  // ...
  static mergeOptions(
    options: NetworkProviderOptions,
    newOptions?: NetworkProviderOptions
  ) {
    // This will override headers and interceptors, instead of joining them
    return {
      ...options,
      ...newOptions,
    }
  }

  extend(options: NetworkProviderOptions) {
    return new NetworkProvider(
      NetworkProvider.mergeOptions(this.options, options)
    )
  }

  async get(url: string, options?: NetworkProviderOptions) {
    return await this.makeRequest(url, {
      ...options,
      method: 'GET',
    })
  }
  // ...
}

It's nothing really hard, we just create a new instance with the same options as the current one and new options passed as parameter. And in client.ts we can use extend method to create a new client with new options.

/**
 * Before request hook to retrieve auth token from local storage and append it to the request.
 */
const authenticateRequest: BeforeRequestInterceptor = (request) => {
  const token = localStorage.getItem('token')
  request.headers.append('Authorization', `Bearer ${token}`)
  return request
}

const privateApi = api.extend({
  interceptors: { beforeRequest: [authenticateRequest] },
})

export { api, privateApi }

And with that, we have two clients, one for authenticated requests and one for unauthenticated ones. But this was just an example, more on this topic in the next lecture.

We are still missing one core feature, error handling. Go to useEvents.ts and implement it.

/**
 * Loads and filters/sorts the event list.
 */
const useEvents = (filter: FilterType) => {
  const [data, setData] = useState<Event[]>([])
  const [error, setError] = useState<string>()
  const [isLoading, setIsLoading] = useState(true)

  // Process filtered/sorted events.
  const listBuilder = listBuilders[filter]
  const list = useMemo(
    () => (data?.length ? listBuilder(data) : []),
    [data, listBuilder]
  )

  // Trigger the event loading.
  useEffect(() => {
    const loadEvents = async () => {
      setIsLoading(true)
      try {
        const response = await api.get('/events')
        const responseData = await response.json()

        // Fetch is throwing on itself just if the request fails on the client side, so we need to throw manually
        // Note that this may not be the best way to handle this, because 1xx, 2xx and 3xx are not really errors, but we are treating them as errors.
        if (response.status !== 200) {
          throw new Error(responseData?.error)
        }

        const data = responseData as Event[]
        setData(data)
        setError(undefined)
      } catch (error) {
        setError((error as Error)?.message)
      } finally {
        setIsLoading(false)
      }
    }
    void loadEvents()
  }, [])

  return { events: list, isLoading, error }
}
Finished network-provider.ts
type BeforeRequestInterceptor = (
  request: Request,
  options: NetworkProviderOptions
) => Request

type AfterRequestInterceptor = (
  request: Request,
  options: NetworkProviderOptions,
  response: Response
) => Response

type NetworkProviderInterceptors = {
  /**
   * Methods run before each request
   */
  beforeRequest?: BeforeRequestInterceptor[]

  /**
   * Methods run after each request
   */
  afterRequest?: AfterRequestInterceptor[]
}

type NetworkProviderOptions = RequestInit & {
  /**
   * Base URL to use for all requests
   */
  baseUrl?: string

  /**
   * Middleware to run before and after each request
   */
  interceptors?: NetworkProviderInterceptors

  /**
   * Data to be stringified and sent with the request
   */
  json?: unknown

  /**
   * Headers to be sent with the request
   */
  headers?: Headers
}

class NetworkProvider {
  private readonly options: NetworkProviderOptions

  constructor(options: NetworkProviderOptions) {
    this.options = options
  }

  static normalizeOptions(options: NetworkProviderOptions) {
    if (!options.headers) {
      options.headers = new Headers()
    }
    if (!options.method) {
      options.method = 'GET'
    }
    if (!options.interceptors) {
      options.interceptors = {}
    }
    if (!options.interceptors.beforeRequest) {
      options.interceptors.beforeRequest = []
    }
    if (!options.interceptors.afterRequest) {
      options.interceptors.afterRequest = []
    }
    return options
  }

  static mergeHeaders(...sources: HeadersInit[]) {
    const result: Record<string, string> = {}

    for (const source of sources) {
      const headers: Headers = new Headers(source)
      let header = headers.entries().next()
      while (!header.done) {
        result[header.value[0]] = header.value[1]
        header = headers.entries().next()
      }
    }

    return new Headers(result)
  }

  static mergeOptions(
    options: NetworkProviderOptions,
    newOptions?: NetworkProviderOptions
  ) {
    const headers = NetworkProvider.mergeHeaders(
      options.headers ?? {},
      newOptions?.headers ?? {}
    )
    const beforeRequest = [
      ...(options.interceptors?.beforeRequest ?? []),
      ...(newOptions?.interceptors?.beforeRequest ?? []),
    ]
    const afterRequest = [
      ...(options.interceptors?.afterRequest ?? []),
      ...(newOptions?.interceptors?.afterRequest ?? []),
    ]
    return {
      ...options,
      ...newOptions,
      headers,
      interceptors: {
        beforeRequest,
        afterRequest,
      },
    }
  }

  extend(options: NetworkProviderOptions) {
    return new NetworkProvider(
      NetworkProvider.mergeOptions(this.options, options)
    )
  }

  async get(url: string, options?: NetworkProviderOptions) {
    return await this.makeRequest(url, {
      ...options,
      method: 'GET',
    })
  }

  async post(url: string, options?: NetworkProviderOptions) {
    return await this.makeRequest(url, {
      ...options,
      method: 'POST',
    })
  }

  private async makeRequest(url: string, options: NetworkProviderOptions) {
    const mergedOptions = NetworkProvider.mergeOptions(this.options, options)
    const {
      baseUrl,
      json: requestJson,
      interceptors,
      ...requestOptions
    } = NetworkProvider.normalizeOptions(mergedOptions)

    // Set content-type header to application/json if not already set
    if (requestJson !== undefined) {
      requestOptions.body = JSON.stringify(requestJson)
      requestOptions?.headers?.set(
        'content-type',
        requestOptions?.headers.get('content-type') ?? 'application/json'
      )
    }

    // Create request with base url
    let request = new Request(
      baseUrl ? new URL(url, baseUrl).toString() : url,
      requestOptions
    )

    // Run before request middleware
    if (interceptors?.beforeRequest?.length) {
      request = interceptors.beforeRequest.reduce(
        (request, interceptor) => interceptor(request, options),
        request
      )
    }

    // Make request
    let response = await fetch(request)

    // Run after request middleware
    if (interceptors?.afterRequest?.length) {
      response = interceptors.afterRequest.reduce(
        (response, interceptor) => interceptor(request, options, response),
        response
      )
    }

    return response
  }
}

export type {
  BeforeRequestInterceptor,
  AfterRequestInterceptor,
  NetworkProviderInterceptors,
  NetworkProviderOptions,
}

export { NetworkProvider }

As for fetch, we are Dan now.

.env

The last thing that we didn't talk about is the .env file. I'll expect you to know what the environment variable in the command line/shell is. Env file is a way of storing those values per project.

❌ NEVER EVER SAVE .env FILE TO GIT, IT MAY CONTAIN SENSITIVE INFO ❌

NextJs automatically loads .env file in the root of the project, but only variables starting with NEXT_PUBLIC_ are exposed to frontend pages (and components ofc). This file should contain all information that is dependent on the environment, meaning dev/staging/production/beta etc. We will move the base url there as well as API key because both of them are dependent on the environment.

NEXT_PUBLIC_API_URL=https://testproject-api-v2.strv.com/
NEXT_PUBLIC_API_KEY=YOUR_KEY

and in components, we can use process.env.NEXT_PUBLIC_API_URL to get the base url, and process.env.NEXT_PUBLIC_API_KEY to get the API key.

Of course, you don't have to use .env file, you can export those values as well. An example of the same in shell would be:

export NEXT_PUBLIC_API_URL=https://testproject-api-v2.strv.com/ && export NEXT_PUBLIC_API_KEY=YOUR_KEY && yarn dev

Actually, in most deployed apps you won't use .env file, but you'll set it up on the hosting provider.

We will now go through how to set it up on Vercel.

1 2 3

PS: As Dan said, it's not really a mistake if you use axios, as long as you know why and what are the alternatives Screenshot 2022-06-17 at 1 25 02

⚠️ **GitHub.com Fallback** ⚠️